diff --git a/VERSION b/VERSION
index c7de632f..50ae9039 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-20250130.2
+20250201
diff --git a/assets/misuzu.js/utility.js b/assets/misuzu.js/utility.js
index cb845f2f..cf35cc39 100644
--- a/assets/misuzu.js/utility.js
+++ b/assets/misuzu.js/utility.js
@@ -106,24 +106,31 @@ const $e = function(info, attrs, child, created) {
         for(const child of children) {
             switch(typeof child) {
                 case 'string':
-                    elem.appendChild($t(child));
+                    elem.appendChild(document.createTextNode(child));
                     break;
 
                 case 'object':
-                    if(child instanceof Element)
+                    if(child instanceof Element) {
                         elem.appendChild(child);
-                    else if(child.getElement) {
+                    } else if('element' in child) {
+                        const childElem = child.element;
+                        if(childElem instanceof Element)
+                            elem.appendChild(childElem);
+                        else
+                            elem.appendChild($e(child));
+                    } else if('getElement' in child) {
                         const childElem = child.getElement();
                         if(childElem instanceof Element)
                             elem.appendChild(childElem);
                         else
                             elem.appendChild($e(child));
-                    } else
+                    } else {
                         elem.appendChild($e(child));
+                    }
                     break;
 
                 default:
-                    elem.appendChild($t(child.toString()));
+                    elem.appendChild(document.createTextNode(child.toString()));
                     break;
             }
         }
diff --git a/assets/oauth2.css/appinfo.css b/assets/oauth2.css/appinfo.css
new file mode 100644
index 00000000..5dd35da3
--- /dev/null
+++ b/assets/oauth2.css/appinfo.css
@@ -0,0 +1,43 @@
+.oauth2-appinfo {}
+
+.oauth2-appinfo-name {
+    font-size: 2em;
+    line-height: 1.5em;
+}
+
+.oauth2-appinfo-links {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 5px;
+}
+
+.oauth2-appinfo-link {
+    display: flex;
+    color: inherit;
+    gap: 5px;
+    align-items: center;
+    background: #333;
+    padding: 2px 6px;
+    border-radius: 4px;
+}
+.oauth2-appinfo-link-icon {
+    flex: 0 0 auto;
+    background-color: #fff;
+    width: 12px;
+    height: 12px;
+}
+.oauth2-appinfo-link-icon-globe {
+    mask: url('/images/globe-solid.svg') no-repeat center;
+}
+.oauth2-appinfo-link-text {
+    font-size: .8em;
+    line-height: 1.4em;
+}
+
+.oauth2-appinfo-summary {
+    font-size: .9em;
+    line-height: 1.4em;
+}
+.oauth2-appinfo-summary p {
+    margin: .5em 0;
+}
diff --git a/assets/oauth2.css/approval.css b/assets/oauth2.css/approval.css
new file mode 100644
index 00000000..598a10b6
--- /dev/null
+++ b/assets/oauth2.css/approval.css
@@ -0,0 +1,35 @@
+.oauth2-approval {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+}
+
+.oauth2-approval-icon {
+    flex: 0 0 auto;
+    background-color: #fff;
+    width: 40px;
+    height: 40px;
+    margin: 10px;
+}
+.oauth2-approval-icon-approved {
+    mask: url('/images/circle-check-solid.svg') no-repeat center;
+}
+.oauth2-approval-icon-denied {
+    mask: url('/images/circle-xmark-solid.svg') no-repeat center;
+}
+
+.oauth2-approval-header {
+    text-align: center;
+    font-size: 1.2em;
+    line-height: 1.5em;
+}
+
+.oauth2-approval-text {
+    width: 100%;
+}
+
+.oauth2-approval-text p {
+    margin: .5em 0;
+    font-size: .8em;
+    line-height: 1.5em;
+}
diff --git a/assets/oauth2.css/authorise.css b/assets/oauth2.css/authorise.css
new file mode 100644
index 00000000..b04a03dc
--- /dev/null
+++ b/assets/oauth2.css/authorise.css
@@ -0,0 +1,72 @@
+.oauth2-authorise-requesting {
+    font-size: .8em;
+    line-height: 1.4em;
+    border-bottom: 1px solid #333;
+}
+.oauth2-authorise-requesting p {
+    margin: .5em 0;
+}
+
+.oauth2-authorise-device {
+    font-size: .8em;
+    line-height: 1.4em;
+}
+.oauth2-authorise-device p {
+    margin: .5em 0;
+}
+
+.oauth2-authorise-buttons {
+    margin-top: 10px;
+    display: flex;
+    justify-content: center;
+    gap: 10px;
+}
+
+.oauth2-authorise-button {
+    background-color: #191919;
+    font-family: var(--font-regular);
+    font-size: 1.2em;
+    line-height: 1.4em;
+    padding: 5px 10px;
+    min-width: 140px;
+    text-align: center;
+    cursor: pointer;
+    transition: color .2s, background-color .2s, opacity .2s;
+    border: 1px solid;
+    border-radius: 2px;
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    text-decoration: none;
+    color: #8559a5;
+    border-color: #8559a5;
+}
+.oauth2-authorise-button:hover,
+.oauth2-authorise-button:focus {
+    color: #191919;
+    background-color: #8559a5;
+}
+
+.oauth2-authorise-button[disabled] {
+    opacity: .5;
+}
+
+.oauth2-authorise-button-accept {
+    color: #080;
+    border-color: #0a0;
+}
+.oauth2-authorise-button-accept:hover,
+.oauth2-authorise-button-accept:focus {
+    color: #191919;
+    background-color: #0a0;
+}
+
+.oauth2-authorise-button-deny {
+    color: #c00;
+    border-color: #a00;
+}
+.oauth2-authorise-button-deny:hover,
+.oauth2-authorise-button-deny:focus {
+    color: #191919;
+    background-color: #a00;
+}
diff --git a/assets/oauth2.css/banner.css b/assets/oauth2.css/banner.css
new file mode 100644
index 00000000..cff6f674
--- /dev/null
+++ b/assets/oauth2.css/banner.css
@@ -0,0 +1,21 @@
+.oauth2-banner {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    margin-bottom: 10px;
+}
+
+.oauth2-banner-text {
+    font-size: .9em;
+    line-height: 1.4em;
+    flex: 1 1 auto;
+}
+.oauth2-banner-logo {
+    flex: 0 0 auto;
+    background-color: #fff;
+    mask: url('/images/flashii.svg') no-repeat center;
+    width: 30px;
+    height: 30px;
+    font-size: 0;
+}
+
diff --git a/assets/oauth2.css/device.css b/assets/oauth2.css/device.css
new file mode 100644
index 00000000..0747d7dd
--- /dev/null
+++ b/assets/oauth2.css/device.css
@@ -0,0 +1,24 @@
+.oauth2-device-form {
+    display: flex;
+    justify-content: center;
+    margin: 10px;
+}
+
+.oauth2-device-code {
+    font-size: 1.4em;
+    border: 1px solid #222;
+    padding: 5px 10px;
+    background: #222;
+    color: #fff;
+    border-radius: 2px;
+    box-shadow: inset 0 0 4px #111;
+    transition: border-color .2s;
+    text-align: center;
+    font-family: var(--font-monospace);
+    min-width: 0;
+    max-width: 200px;
+    width: 100%;
+}
+.oauth2-device-code:focus {
+    border-color: #8559a5;
+}
diff --git a/assets/oauth2.css/error.css b/assets/oauth2.css/error.css
new file mode 100644
index 00000000..41f3b681
--- /dev/null
+++ b/assets/oauth2.css/error.css
@@ -0,0 +1,3 @@
+.oauth2-errorbody p {
+    margin: .5em 1em;
+}
diff --git a/assets/oauth2.css/loading.css b/assets/oauth2.css/loading.css
new file mode 100644
index 00000000..925d9471
--- /dev/null
+++ b/assets/oauth2.css/loading.css
@@ -0,0 +1,36 @@
+.oauth2-loading {
+    display: flex;
+    justify-content: center;
+    flex-direction: column;
+    min-height: 200px;
+}
+
+.oauth2-loading-frame {
+    display: flex;
+    justify-content: center;
+    flex: 0 0 auto;
+}
+
+.oauth2-loading-icon {
+    display: grid;
+    grid-template-columns: repeat(3, 1fr);
+    grid-template-rows: repeat(3, 1fr);
+    gap: 2px;
+    margin: 20px;
+}
+
+.oauth2-loading-icon-block {
+    background: #fff;
+    width: 20px;
+    height: 20px;
+}
+
+.oauth2-loading-icon-block-hidden {
+    opacity: 0;
+}
+
+.oauth2-loading-text {
+    text-align: center;
+    font-size: 1.2em;
+    line-height: 1.5em;
+}
diff --git a/assets/oauth2.css/main.css b/assets/oauth2.css/main.css
new file mode 100644
index 00000000..cf9e4bce
--- /dev/null
+++ b/assets/oauth2.css/main.css
@@ -0,0 +1,97 @@
+* {
+    margin: 0;
+    padding: 0;
+    box-sizing: border-box;
+    position: relative;
+}
+
+html, body {
+    width: 100%;
+    height: 100%;
+}
+
+[hidden],
+.hidden {
+    display: none !important;
+}
+
+:root {
+    --font-regular: Verdana, Geneva, 'Dejavu Sans', Arial, Helvetica, sans-serif;
+    --font-monospace: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
+}
+
+body {
+    background-color: #111;
+    color: #fff;
+    font-size: 16px;
+    line-height: 25px;
+    font-family: var(--font-regular);
+    overflow-y: scroll;
+    position: static;
+    display: flex;
+    flex-direction: column;
+}
+
+pre, code {
+    font-family: var(--font-monospace);
+}
+
+a {
+    color: #1e90ff;
+    text-decoration: none;
+}
+a:visited {
+    color: #6B4F80;
+}
+a:hover,
+a:focus {
+    text-decoration: underline;
+}
+
+.oauth2-wrapper {
+    display: flex;
+    flex-direction: column;
+    flex: 1 0 auto;
+    margin-bottom: 10px;
+}
+
+.oauth2-dialog {
+    display: flex;
+    flex: 1 0 auto;
+    padding: 10px;
+    width: 100%;
+    align-items: center;
+    justify-content: center;
+}
+
+.oauth2-dialog-body {
+    max-width: 500px;
+    width: 100%;
+    background: #191919;
+    box-shadow: 0 1px 2px #0009;
+    display: flex;
+    flex-direction: column;
+}
+
+.oauth2-header {
+    background-image: url('/images/clouds.png');
+    background-blend-mode: multiply;
+    background-color: #8559a5;
+    width: 100%;
+    min-height: 4px;
+}
+
+.oauth2-body {
+    margin: 10px;
+}
+
+@include loading.css;
+@include banner.css;
+@include error.css;
+@include device.css;
+@include simplehead.css;
+@include userhead.css;
+@include appinfo.css;
+@include scope.css;
+@include authorise.css;
+@include approval.css;
diff --git a/assets/oauth2.css/scope.css b/assets/oauth2.css/scope.css
new file mode 100644
index 00000000..84a7b221
--- /dev/null
+++ b/assets/oauth2.css/scope.css
@@ -0,0 +1,38 @@
+.oauth2-scope {
+    background: #292929;
+    border-radius: 4px;
+    padding: 4px 8px;
+}
+
+.oauth2-scope-header {
+    border-bottom: 1px solid #494949;
+}
+
+.oauth2-scope-perms {
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+    margin: 4px 0;
+}
+
+.oauth2-scope-perm {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+}
+.oauth2-scope-perm-icon {
+    width: 16px;
+    height: 16px;
+    mask: url('/images/circle-check-regular.svg') no-repeat center;
+    flex: 0 0 auto;
+    background-color: #0a0;
+    margin: 2px;
+}
+.oauth2-scope-perm-icon-warn {
+    mask: url('/images/circle-regular.svg') no-repeat center, url('/images/exclamation-solid.svg') no-repeat center center / 10px 10px;
+    background-color: #c80;
+}
+.oauth2-scope-perm-text {
+    font-size: .8em;
+    line-height: 1.4em;
+}
diff --git a/assets/oauth2.css/simplehead.css b/assets/oauth2.css/simplehead.css
new file mode 100644
index 00000000..639c1da7
--- /dev/null
+++ b/assets/oauth2.css/simplehead.css
@@ -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;
+}
diff --git a/assets/oauth2.css/userhead.css b/assets/oauth2.css/userhead.css
new file mode 100644
index 00000000..b8e5efe5
--- /dev/null
+++ b/assets/oauth2.css/userhead.css
@@ -0,0 +1,64 @@
+.oauth2-userhead {
+    display: flex;
+    flex-direction: column;
+    background-color: #0005;
+}
+
+.oauth2-userhead-main {
+    display: flex;
+    align-items: center;
+}
+.oauth2-userhead-main-avatar {
+    flex: 0 0 auto;
+    margin: 10px;
+}
+.oauth2-userhead-main-avatar-image {
+    width: 60px;
+    height: 60px;
+    overflow: hidden;
+    border-radius: 4px;
+}
+.oauth2-userhead-main-avatar-image img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    border: 0;
+}
+.oauth2-userhead-main-name {
+    font-size: 1.8em;
+    line-height: 1.4em;
+}
+.oauth2-userhead-main-name a {
+    color: inherit;
+}
+
+.oauth2-userhead-guise {
+    display: flex;
+    align-items: center;
+    background-image: repeating-linear-gradient(-45deg, #8559a57f, #8559a57f 10px, #1111117f 10px, #1111117f 20px);
+}
+.oauth2-userhead-guise-avatar {
+    flex: 0 0 auto;
+    margin: 10px;
+}
+.oauth2-userhead-guise-avatar-image {
+    width: 30px;
+    height: 30px;
+    overflow: hidden;
+    border-radius: 4px;
+}
+.oauth2-userhead-guise-avatar-image img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    border: 0;
+}
+.oauth2-userhead-guise-text {
+    font-size: .8em;
+    line-height: 1.5em;
+    overflow: hidden;
+    padding: 2px 0;
+}
+.oauth2-userhead-guise-text p {
+    margin: 1px 0;
+}
diff --git a/assets/oauth2.js/app/info.jsx b/assets/oauth2.js/app/info.jsx
new file mode 100644
index 00000000..e7962923
--- /dev/null
+++ b/assets/oauth2.js/app/info.jsx
@@ -0,0 +1,30 @@
+const MszOAuth2AppInfoLink = function(info) {
+    const element = <a href={info.uri} target="_blank" rel="noopener noreferrer" class="oauth2-appinfo-link" title={info.title}>
+        <div class="oauth2-appinfo-link-icon oauth2-appinfo-link-icon-globe"></div>
+        <div class="oauth2-appinfo-link-text">{info.display}</div>
+    </a>;
+
+    return {
+        get element() { return element; },
+    };
+};
+
+const MszOAuth2AppInfo = function(info) {
+    const linksElem = <div class="oauth2-appinfo-links"/>;
+    if(Array.isArray(info.links))
+        for(const link of info.links)
+            linksElem.appendChild((new MszOAuth2AppInfoLink(link)).element);
+
+    // TODO: author should be listed
+    const element = <div class="oauth2-appinfo">
+        <div class="oauth2-appinfo-name">{info.name}</div>
+        {linksElem}
+        <div class="oauth2-appinfo-summary">
+            <p>{info.summary}</p>
+        </div>
+    </div>;
+
+    return {
+        get element() { return element; },
+    };
+};
diff --git a/assets/oauth2.js/app/scope.jsx b/assets/oauth2.js/app/scope.jsx
new file mode 100644
index 00000000..d9e69b90
--- /dev/null
+++ b/assets/oauth2.js/app/scope.jsx
@@ -0,0 +1,33 @@
+const MszOAuth2AppScopeEntry = function(text, warn) {
+    const icon = <div class="oauth2-scope-perm-icon"/>;
+    if(warn)
+        icon.classList.add('oauth2-scope-perm-icon-warn');
+
+    const element = <div class="oauth2-scope-perm">
+        {icon}
+        <div class="oauth2-scope-perm-text">{text}</div>
+    </div>;
+
+    return {
+        get element() { return element; },
+    };
+};
+
+const MszOAuth2AppScopeList = function(scopes) {
+    const permsElem = <div class="oauth2-scope-perms"/>;
+    if(Array.isArray(scopes) && scopes.length > 0) {
+        for(const scope of scopes)
+            if(typeof scope === 'string')
+                permsElem.appendChild(new MszOAuth2AppScopeEntry(scope).element);
+    } else
+        permsElem.appendChild(new MszOAuth2AppScopeEntry('A limited amount of things. No scope was specified by the developer.', true).element);
+
+    const element = <div class="oauth2-scope">
+        <div class="oauth2-scope-header">This application will be able to:</div>
+        {permsElem}
+    </div>;
+
+    return {
+        get element() { return element; },
+    };
+};
diff --git a/assets/oauth2.js/authorise.js b/assets/oauth2.js/authorise.js
new file mode 100644
index 00000000..7b4f4556
--- /dev/null
+++ b/assets/oauth2.js/authorise.js
@@ -0,0 +1,232 @@
+#include loading.jsx
+#include xhr.js
+#include app/info.jsx
+#include app/scope.jsx
+#include header/header.js
+#include header/user.jsx
+
+const MszOAuth2AuthoriseErrors = Object.freeze({
+    'invalid_request': {
+        description: 'The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed.',
+    },
+    'unauthorized_client': {
+        description: 'The client is not authorised to request an authorisation code using this method.',
+    },
+    'access_denied': {
+        description: 'The resource owner or authorization server denied the request.',
+    },
+    'unsupported_response_type': {
+        description: 'The authorisation server does not support obtaining an authorisation code using this method.',
+    },
+    'invalid_scope': {
+        description: 'The requested scope is invalid, unknown, or malformed.',
+    },
+    'server_error': {
+        description: 'The authorisation server encountered an unexpected condition that prevented it from fulfilling the request.',
+    },
+    'temporarily_unavailable': {
+        description: 'The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.',
+    },
+});
+
+const MszOAuth2Authorise = async () => {
+    const queryParams = new URLSearchParams(window.location.search);
+    const loading = new MszOAuth2Loading('.js-loading');
+    const header = new MszOAuth2Header;
+
+    const fAuths = document.querySelector('.js-authorise-form');
+    const eAuthsInfo = document.querySelector('.js-authorise-form-info');
+    const eAuthsScope = document.querySelector('.js-authorise-form-scope');
+
+    const dError = document.querySelector('.js-authorise-error');
+    const dErrorText = dError?.querySelector('.js-authorise-error-text');
+
+    let scope;
+    let state;
+    let redirectUri;
+    let redirectUriRaw;
+
+    const displayError = (error, description, documentation) => {
+        if(redirectUri === undefined) {
+            if(error in MszOAuth2AuthoriseErrors) {
+                const errInfo = MszOAuth2AuthoriseErrors[error];
+                description ??= errInfo.description;
+            } else
+                description = `An unknown error occurred: ${error}`;
+
+            dErrorText.textContent = description;
+
+            header.setSimpleData('error', 'An error occurred!');
+            header.removeElement();
+
+            loading.visible = false;
+            fAuths.classList.add('hidden');
+            dError.classList.remove('hidden');
+            return;
+        }
+
+        const errorUri = new URL(redirectUri);
+        errorUri.searchParams.set('error', error?.toString() ?? 'invalid_request');
+        if(description)
+            errorUri.searchParams.set('error_description', description.toString());
+        if(documentation)
+            errorUri.searchParams.set('error_uri', documentation.toString());
+        if(state !== undefined)
+            errorUri.searchParams.set('state', state.toString());
+
+        window.location.assign(errorUri);
+    };
+    const translateError = (serverError, detail) => {
+        if(serverError === 'auth')
+            return displayError('access_denied');
+        if(serverError === 'csrf')
+            return displayError('invalid_request', 'Request verification failed.');
+        if(serverError === 'client')
+            return displayError('invalid_request', 'There is no application associated with the specified Client ID.');
+        if(serverError === 'format')
+            return displayError('invalid_request', 'Redirect URI specified is not registered with this application.');
+        if(serverError === 'method')
+            return displayError('invalid_request', 'Requested code challenge method is not supported.');
+        if(serverError === 'length')
+            return displayError('invalid_request', 'Code challenge length is not acceptable.');
+        if(serverError === 'required')
+            return displayError('invalid_request', 'A registered redirect URI must be specified.');
+        if(serverError === 'scope')
+            return displayError('invalid_scope', detail === undefined ? undefined : `Requested scope "${detail.scope}" is ${detail.reason}.`);
+        if(serverError === 'authorise')
+            return displayError('server_error', 'Server was unable to complete authorisation.');
+
+        return displayError('invalid_request', `An unknown error occurred: ${serverError}.`);
+    };
+
+    if(queryParams.has('redirect_uri'))
+        try {
+            const qRedirectUriRaw = queryParams.get('redirect_uri');
+            const qRedirectUri = new URL(qRedirectUriRaw);
+            if(qRedirectUri.protocol !== 'https:')
+                throw 'protocol must be https';
+
+            redirectUri = qRedirectUri;
+            redirectUriRaw = qRedirectUriRaw;
+        } catch(ex) {
+            return displayError('invalid_request', 'Invalid redirect URI specified.');
+        }
+
+    if(queryParams.has('state')) {
+        const qState = queryParams.get('state');
+
+        if(qState.length > 1000)
+            return displayError('invalid_request', 'State parameter may not be longer than 255 characters.');
+
+        state = qState;
+    }
+
+    if(queryParams.get('response_type') !== 'code')
+        return displayError('unsupported_response_type');
+
+    let codeChallengeMethod = 'plain';
+    if(queryParams.has('code_challenge_method')) {
+        codeChallengeMethod = queryParams.get('code_challenge_method');
+        if(!['plain', 'S256'].includes(codeChallengeMethod))
+            return translateError('method');
+    }
+
+    if(!queryParams.has('code_challenge'))
+        return displayError('invalid_request', 'code_challenge must be specified.');
+
+    const codeChallenge = queryParams.get('code_challenge');
+    if(codeChallengeMethod === 'S256') {
+        if(codeChallenge.length !== 43)
+            return displayError('invalid_request', 'Specified code challenge is not a valid SHA-256 hash.');
+    } else {
+        if(codeChallenge.length < 43)
+            return displayError('invalid_request', 'Code challenge must be at least 43 characters long.');
+        if(codeChallenge.length > 128)
+            return displayError('invalid_request', 'Code challenge may not be longer than 128 characters.');
+    }
+
+    if(!queryParams.has('client_id'))
+        return displayError('invalid_request', 'client_id must be specified.');
+
+    const resolveParams = new URLSearchParams;
+    resolveParams.set('client', queryParams.get('client_id'));
+    if(redirectUriRaw !== undefined)
+        resolveParams.set('redirect', redirectUriRaw);
+    if(queryParams.has('scope')) {
+        scope = queryParams.get('scope');
+        resolveParams.set('scope', scope);
+    }
+
+    try {
+        const { body } = await $x.get(`/oauth2/resolve-authorise-app?${resolveParams}`, { authed: true, csrf: true, type: 'json' });
+        if(!body)
+            throw 'authorisation resolve failed';
+        if(typeof body.error === 'string')
+            return translateError(body.error, body);
+
+        const userHeader = new MszOAuth2UserHeader(body.user);
+        header.setElement(userHeader);
+
+        const verifyAuthsRequest = async () => {
+            const params = {
+                client: queryParams.get('client_id'),
+                cc: codeChallenge,
+                ccm: codeChallengeMethod,
+            };
+            if(redirectUriRaw !== undefined)
+                params.redirect = redirectUriRaw;
+            if(scope !== undefined)
+                params.scope = scope;
+
+            try {
+                const { body } = await $x.post('/oauth2/authorise', { authed: true, csrf: true, type: 'json' }, params);
+                if(!body)
+                    throw 'authorisation failed';
+                if(typeof body.error === 'string')
+                    return translateError(body.error, body);
+
+                const authoriseUri = new URL(body.redirect);
+                authoriseUri.searchParams.set('code', body.code);
+                if(state !== undefined)
+                    authoriseUri.searchParams.set('state', state.toString());
+
+                window.location.assign(authoriseUri);
+            } catch(ex) {
+                console.error(ex);
+                translateError('authorise');
+            }
+        };
+
+        if(body.app.trusted && body.user.guise === undefined) {
+            if(userHeader)
+                userHeader.guiseVisible = false;
+
+            verifyAuthsRequest();
+            return;
+        }
+
+        eAuthsInfo.replaceWith(new MszOAuth2AppInfo(body.app).element);
+        eAuthsScope.replaceWith(new MszOAuth2AppScopeList(body.scope).element);
+
+        fAuths.onsubmit = ev => {
+            ev.preventDefault();
+
+            loading.visible = true;
+            fAuths.classList.add('hidden');
+
+            if(userHeader)
+                userHeader.guiseVisible = false;
+
+            if(ev.submitter?.value === 'yes')
+                verifyAuthsRequest();
+            else
+                displayError('access_denied');
+        };
+
+        loading.visible = false;
+        fAuths.classList.remove('hidden');
+    } catch(ex) {
+        console.error(ex);
+        displayError('server_error', 'Server was unable to respond to the client info request.');
+    }
+};
diff --git a/assets/oauth2.js/csrf.js b/assets/oauth2.js/csrf.js
new file mode 100644
index 00000000..7df0610b
--- /dev/null
+++ b/assets/oauth2.js/csrf.js
@@ -0,0 +1,24 @@
+#include utility.js
+
+const MszCSRF = (() => {
+    let elem;
+    const getElement = () => {
+        if(elem === undefined)
+            elem = $q('meta[name="csrf-token"]');
+        return elem;
+    };
+
+    return {
+        get token() {
+            return getElement()?.content ?? '';
+        },
+        set token(token) {
+            if(typeof token !== 'string')
+                throw 'token must be a string';
+
+            const elem = getElement();
+            if(elem instanceof HTMLMetaElement)
+                elem.content = token;
+        },
+    };
+})();
diff --git a/assets/oauth2.js/header/header.js b/assets/oauth2.js/header/header.js
new file mode 100644
index 00000000..a515f613
--- /dev/null
+++ b/assets/oauth2.js/header/header.js
@@ -0,0 +1,57 @@
+const MszOAuth2Header = function(element = '.js-oauth2-header', simpleElement = '.js-oauth2-header-simple') {
+    if(typeof element === 'string')
+        element = document.querySelector(element);
+    if(!(element instanceof HTMLElement))
+        throw 'element must be a valid query selector or an instance of HTMLElement';
+
+    if(typeof simpleElement === 'string')
+        simpleElement = element.querySelector(simpleElement);
+
+    const simpleElementIcon = simpleElement?.querySelector('.js-oauth2-header-simple-icon');
+    const simpleElementText = simpleElement?.querySelector('.js-oauth2-header-simple-text');
+
+    const hasSimpleElement = simpleElement instanceof HTMLElement;
+    const setSimpleVisible = state => {
+        if(hasSimpleElement)
+            simpleElement.classList.toggle('hidden', !state);
+    };
+    const setSimpleData = (icon, text) => {
+        if(hasSimpleElement) {
+            for(const className of simpleElementIcon.classList)
+                if(className.startsWith('oauth2-simplehead-icon--'))
+                    simpleElementIcon.classList.remove(className);
+
+            simpleElementIcon.classList.add(`oauth2-simplehead-icon--${icon}`);
+            simpleElementText.textContent = text;
+        }
+    };
+
+    const removeElement = (forceSimple = true) => {
+        while(element.childElementCount > 1)
+            element.lastElementChild.remove();
+
+        if(typeof forceSimple === 'boolean')
+            setSimpleVisible(forceSimple);
+    };
+
+    return {
+        get element() { return element; },
+
+        get simpleVisible() { return hasSimpleElement && !simpleElement.classList.contains('hidden'); },
+        set simpleVisible(state) { setSimpleVisible(state); },
+
+        setSimpleData: setSimpleData,
+
+        setElement: elementInfo => {
+            removeElement(false);
+
+            if(elementInfo instanceof Element)
+                element.appendChild(elementInfo);
+            else if('element' in elementInfo)
+                element.appendChild(elementInfo.element);
+            else
+                throw 'elementInfo must be an instance of Element or contain an object with an element property';
+        },
+        removeElement: removeElement,
+    };
+};
diff --git a/assets/oauth2.js/header/user.jsx b/assets/oauth2.js/header/user.jsx
new file mode 100644
index 00000000..4ca95630
--- /dev/null
+++ b/assets/oauth2.js/header/user.jsx
@@ -0,0 +1,53 @@
+const MszOAuth2UserGuiseHeader = function(guise) {
+    const element = <div class="oauth2-userhead-guise">
+        <div class="oauth2-userhead-guise-avatar">
+            <div class="oauth2-userhead-guise-avatar-image">
+                <img src={guise.avatar_uri} alt=""/>
+            </div>
+        </div>
+        <div class="oauth2-userhead-guise-text">
+            <p>Are you <a href={guise.profile_uri} target="_blank" style={`color: ${guise.colour}`}>{guise.name}</a> and did you mean to use your own account?</p>
+            <p><a href={guise.revert_uri} target="_blank">Click here</a> and reload this page.</p>
+        </div>
+    </div>;
+
+    return {
+        get element() { return element; },
+
+        get visible() { return !element.classList.contains('hidden'); },
+        set visible(state) {
+            element.classList.toggle('hidden', !state);
+        },
+    };
+};
+
+const MszOAuth2UserHeader = function(user) {
+    const element = <div class="oauth2-userhead">
+        <div class="oauth2-userhead-main">
+            <div class="oauth2-userhead-main-avatar">
+                <div class="oauth2-userhead-main-avatar-image">
+                    <img src={user.avatar_uri} alt=""/>
+                </div>
+            </div>
+            <div class="oauth2-userhead-main-name">
+                <a href={user.profile_uri} target="_blank">{user.name}</a>
+            </div>
+        </div>
+    </div>;
+
+    let guiseInfo;
+    if(user.guise) {
+        guiseInfo = new MszOAuth2UserGuiseHeader(user.guise);
+        element.appendChild(guiseInfo.element);
+    }
+
+    return {
+        get element() { return element; },
+
+        get guiseVisible() { return guiseInfo?.visible === true; },
+        set guiseVisible(state) {
+            if(guiseInfo !== undefined)
+                guiseInfo.visible = state;
+        },
+    };
+};
diff --git a/assets/oauth2.js/loading.jsx b/assets/oauth2.js/loading.jsx
new file mode 100644
index 00000000..96f0e11a
--- /dev/null
+++ b/assets/oauth2.js/loading.jsx
@@ -0,0 +1,84 @@
+const MszOAuth2LoadingIcon = function() {
+    const element = <div class="oauth2-loading-icon"/>;
+    for(let i = 0; i < 9; ++i)
+        element.appendChild(<div class="oauth2-loading-icon-block"/>);
+
+    // this is moderately cursed but it'll do
+    const blocks = [
+        element.children[3],
+        element.children[0],
+        element.children[1],
+        element.children[2],
+        element.children[5],
+        element.children[8],
+        element.children[7],
+        element.children[6],
+    ];
+
+    let tsLastUpdate;
+    let counter = 0;
+    let playing = false;
+
+    const update = tsCurrent => {
+        try {
+            if(tsLastUpdate !== undefined && (tsCurrent - tsLastUpdate) < 50)
+                return;
+            tsLastUpdate = tsCurrent;
+
+            for(let i = 0; i < blocks.length; ++i)
+                blocks[(counter + i) % blocks.length].classList.toggle('oauth2-loading-icon-block-hidden', i < 3);
+
+            ++counter;
+        } finally {
+            if(playing)
+                requestAnimationFrame(update);
+        }
+    };
+
+    const play = () => {
+        if(playing)
+            return;
+        playing = true;
+        requestAnimationFrame(update);
+    };
+    const pause = () => { playing = false; };
+    const stop = () => { pause(); counter = 0; };
+    const restart = () => { stop(); play(); };
+
+    return {
+        get element() { return element; },
+        get playing() { return playing; },
+
+        play: play,
+        pause: pause,
+        stop: stop,
+        restart: restart,
+    };
+};
+
+const MszOAuth2Loading = function(element) {
+    if(typeof element === 'string')
+        element = document.querySelector(element);
+    if(!(element instanceof HTMLElement))
+        element = <div class="oauth2-loading"/>;
+
+    if(!element.classList.contains('oauth2-loading'))
+        element.classList.add('oauth2-loading');
+
+    let icon;
+    if(element.childElementCount < 1) {
+        icon = new MszOAuth2LoadingIcon;
+        icon.play();
+        element.appendChild(<div class="oauth2-loading-frame">{icon}</div>);
+    }
+
+    return {
+        get element() { return element; },
+
+        get hasIcon() { return icon !== undefined; },
+        get icon() { return icon; },
+
+        get visible() { return !element.classList.contains('hidden'); },
+        set visible(state) { element.classList.toggle('hidden', !state); },
+    };
+};
diff --git a/assets/oauth2.js/main.js b/assets/oauth2.js/main.js
new file mode 100644
index 00000000..e7238927
--- /dev/null
+++ b/assets/oauth2.js/main.js
@@ -0,0 +1,10 @@
+#include utility.js
+#include authorise.js
+#include verify.js
+
+(() => {
+    if(location.pathname === '/oauth2/authorise' || location.pathname === '/oauth2/authorize')
+        MszOAuth2Authorise();
+    if(location.pathname === '/oauth2/verify')
+        MszOAuth2Verify();
+})();
diff --git a/assets/oauth2.js/utility.js b/assets/oauth2.js/utility.js
new file mode 100644
index 00000000..28bc48cd
--- /dev/null
+++ b/assets/oauth2.js/utility.js
@@ -0,0 +1,170 @@
+const $i = document.getElementById.bind(document);
+const $c = document.getElementsByClassName.bind(document);
+const $q = document.querySelector.bind(document);
+const $qa = document.querySelectorAll.bind(document);
+const $t = document.createTextNode.bind(document);
+
+const $r = function(element) {
+    if(element && element.parentNode)
+        element.parentNode.removeChild(element);
+};
+
+const $ri = function(name) {
+    $r($i(name));
+};
+
+const $rq = function(query) {
+    $r($q(query));
+};
+
+const $ib = function(ref, elem) {
+    ref.parentNode.insertBefore(elem, ref);
+};
+
+const $rc = function(element) {
+    while(element.lastChild)
+        element.removeChild(element.lastChild);
+};
+
+const $e = function(info, attrs, child, created) {
+    info = info || {};
+
+    if(typeof info === 'string') {
+        info = {tag: info};
+        if(attrs)
+            info.attrs = attrs;
+        if(child)
+            info.child = child;
+        if(created)
+            info.created = created;
+    }
+
+    const elem = document.createElement(info.tag || 'div');
+
+    if(info.attrs) {
+        const attrs = info.attrs;
+
+        for(let key in attrs) {
+            const attr = attrs[key];
+            if(attr === undefined || attr === null)
+                continue;
+
+            switch(typeof attr) {
+                case 'function':
+                    if(key.substring(0, 2) === 'on')
+                        key = key.substring(2).toLowerCase();
+                    elem.addEventListener(key, attr);
+                    break;
+
+                case 'object':
+                    if(attr instanceof Array) {
+                        if(key === 'class')
+                            key = 'classList';
+
+                        const prop = elem[key];
+                        let addFunc = null;
+
+                        if(prop instanceof Array)
+                            addFunc = prop.push.bind(prop);
+                        else if(prop instanceof DOMTokenList)
+                            addFunc = prop.add.bind(prop);
+
+                        if(addFunc !== null) {
+                            for(let j = 0; j < attr.length; ++j)
+                                addFunc(attr[j]);
+                        } else {
+                            if(key === 'classList')
+                                key = 'class';
+                            elem.setAttribute(key, attr.toString());
+                        }
+                    } else {
+                        for(const attrKey in attr)
+                            elem[key][attrKey] = attr[attrKey];
+                    }
+                    break;
+
+                case 'boolean':
+                    if(attr)
+                        elem.setAttribute(key, '');
+                    break;
+
+                default:
+                    if(key === 'className')
+                        key = 'class';
+                    elem.setAttribute(key, attr.toString());
+                    break;
+            }
+        }
+    }
+
+    if(info.child) {
+        let children = info.child;
+
+        if(!Array.isArray(children))
+            children = [children];
+
+        for(const child of children) {
+            switch(typeof child) {
+                case 'string':
+                    elem.appendChild(document.createTextNode(child));
+                    break;
+
+                case 'object':
+                    if(child instanceof Element) {
+                        elem.appendChild(child);
+                    } else if('element' in child) {
+                        const childElem = child.element;
+                        if(childElem instanceof Element)
+                            elem.appendChild(childElem);
+                        else
+                            elem.appendChild($e(child));
+                    } else if('getElement' in child) {
+                        const childElem = child.getElement();
+                        if(childElem instanceof Element)
+                            elem.appendChild(childElem);
+                        else
+                            elem.appendChild($e(child));
+                    } else {
+                        elem.appendChild($e(child));
+                    }
+                    break;
+
+                default:
+                    elem.appendChild(document.createTextNode(child.toString()));
+                    break;
+            }
+        }
+    }
+
+    if(info.created)
+        info.created(elem);
+
+    return elem;
+};
+const $er = (type, props, ...children) => $e({ tag: type, attrs: props, child: children });
+
+const $ar = function(array, index) {
+    array.splice(index, 1);
+};
+const $ari = function(array, item) {
+    let index;
+    while(array.length > 0 && (index = array.indexOf(item)) >= 0)
+        $ar(array, index);
+};
+const $arf = function(array, predicate) {
+    let index;
+    while(array.length > 0 && (index = array.findIndex(predicate)) >= 0)
+        $ar(array, index);
+};
+
+const $as = function(array) {
+    if(array.length < 2)
+        return;
+
+    for(let i = array.length - 1; i > 0; --i) {
+        let j = Math.floor(Math.random() * (i + 1)),
+            tmp = array[i];
+        array[i] = array[j];
+        array[j] = tmp;
+    }
+};
diff --git a/assets/oauth2.js/verify.js b/assets/oauth2.js/verify.js
new file mode 100644
index 00000000..5c5c09fa
--- /dev/null
+++ b/assets/oauth2.js/verify.js
@@ -0,0 +1,178 @@
+#include loading.jsx
+#include xhr.js
+#include app/info.jsx
+#include app/scope.jsx
+#include header/header.js
+#include header/user.jsx
+
+const MszOAuth2Verify = () => {
+    const queryParams = new URLSearchParams(window.location.search);
+    const loading = new MszOAuth2Loading('.js-loading');
+    const header = new MszOAuth2Header;
+
+    const fAuths = document.querySelector('.js-verify-authorise');
+    const eAuthsInfo = document.querySelector('.js-verify-authorise-info');
+    const eAuthsScope = document.querySelector('.js-verify-authorise-scope');
+
+    const rApproved = document.querySelector('.js-verify-approved');
+    const rDenied = document.querySelector('.js-verify-denied');
+
+    let userCode = '';
+    let userHeader;
+
+    const verifyAuthsRequest = async approve => {
+        try {
+            const { body } = await $x.post('/oauth2/verify', { authed: true, csrf: true, type: 'json' }, {
+                code: userCode,
+                approve: approve === true ? 'yes' : 'no',
+            });
+
+            if(!body)
+                throw 'response is empty';
+
+            if(typeof body.error === 'string') {
+                // TODO: nicer errors
+                if(body.error === 'auth')
+                    alert('You are not logged in.');
+                else if(body.error === 'csrf')
+                    alert('Request verification failed, please refresh and try again.');
+                else if(body.error === 'code')
+                    alert('This code is not associated with any authorisation request.');
+                else if(body.error === 'approval')
+                    alert('The authorisation request associated with this code is not pending approval.');
+                else if(body.error === 'expired')
+                    alert('The authorisation request has expired, please restart the process from the application or device.');
+                else if(body.error === 'invalid')
+                    alert('Invalid approval state specified.');
+                else if(body.error === 'scope') {
+                    alert(`Requested scope "${body.scope}" is ${body.reason}.`);
+                    loading.visible = false;
+                    rDenied.classList.remove('hidden');
+                    return;
+                } else
+                    alert(`An unknown error occurred: ${body.error}`);
+
+                loading.visible = false;
+                fAuths.classList.remove('hidden');
+                return;
+            }
+
+            loading.visible = false;
+            if(body.approval === 'approved')
+                rApproved.classList.remove('hidden');
+            else
+                rDenied.classList.remove('hidden');
+        } catch(ex) {
+            alert('Request to verify endpoint failed. Please try again.');
+            loading.visible = false;
+            fAuths.classList.remove('hidden');
+        }
+    };
+
+    fAuths.onsubmit = ev => {
+        ev.preventDefault();
+
+        loading.visible = true;
+        fAuths.classList.add('hidden');
+
+        if(userHeader)
+            userHeader.guiseVisible = false;
+
+        verifyAuthsRequest(ev.submitter.value === 'yes');
+    };
+
+    const fCode = document.querySelector('.js-verify-code');
+    const eUserCode = fCode.elements.namedItem('code');
+
+    fCode.onsubmit = ev => {
+        ev.preventDefault();
+
+        loading.visible = true;
+        fCode.classList.add('hidden');
+
+        userCode = encodeURIComponent(eUserCode.value);
+        $x.get(`/oauth2/resolve-verify?code=${userCode}`, { authed: true, csrf: true, type: 'json' })
+            .then(result => {
+                const body = result.body;
+
+                if(!body) {
+                    alert('Request to resolve endpoint failed. Please try again.');
+                    loading.visible = false;
+                    fCode.classList.remove('hidden');
+                    return;
+                }
+
+                if(typeof body.error === 'string') {
+                    // TODO: nicer errors
+                    if(body.error === 'auth')
+                        alert('You are not logged in.');
+                    else if(body.error === 'csrf')
+                        alert('Request verification failed, please refresh and try again.');
+                    else if(body.error === 'code')
+                        alert('This code is not associated with any authorisation request.');
+                    else if(body.error === 'expired')
+                        alert('The authorisation request has expired, please restart the process from the application or device.');
+                    else if(body.error === 'approval')
+                        alert('The authorisation request associated with this code is not pending approval.');
+                    else if(body.error === 'scope') {
+                        verifyAuthsRequest(false).finally(() => {
+                            alert(`Requested scope "${body.scope}" is ${body.reason}.`);
+                        });
+                        return;
+                    } else
+                        alert(`An unknown error occurred: ${body.error}`);
+
+                    loading.visible = false;
+                    fCode.classList.remove('hidden');
+                    return;
+                }
+
+                userCode = body.req.code;
+
+                userHeader = new MszOAuth2UserHeader(body.user);
+                header.setElement(userHeader);
+
+                if(body.app.trusted && body.user.guise === undefined) {
+                    if(userHeader)
+                        userHeader.guiseVisible = false;
+
+                    verifyAuthsRequest(true);
+                    return;
+                }
+
+                eAuthsInfo.replaceWith(new MszOAuth2AppInfo(body.app).element);
+                eAuthsScope.replaceWith(new MszOAuth2AppScopeList(body.scope).element);
+
+                loading.visible = false;
+                fAuths.classList.remove('hidden');
+            }).catch(() => {
+                alert('Request to resolve endpoint failed. Please try again.');
+                loading.visible = false;
+                fCode.classList.remove('hidden');
+            });
+    };
+
+    const validateCodeInput = () => {
+        // [A-Za-z0-8]{3}\-[A-Za-z0-8]{3}\-[A-Za-z0-8]{3}
+        // 0 -> O, 1 -> I, 8 -> B
+
+        const eCode = eUserCode.value;
+
+        return eCode.length > 0;
+    };
+
+    eUserCode.oninput = () => {
+        validateCodeInput();
+        console.warn(eUserCode.value);
+    };
+
+    if(queryParams.has('code') && eUserCode.value === '')
+        eUserCode.value = queryParams.get('code');
+
+    if(validateCodeInput()) {
+        fCode.requestSubmit();
+    } else {
+        loading.visible = false;
+        fCode.classList.remove('hidden');
+    }
+};
diff --git a/assets/oauth2.js/xhr.js b/assets/oauth2.js/xhr.js
new file mode 100644
index 00000000..c6e4b870
--- /dev/null
+++ b/assets/oauth2.js/xhr.js
@@ -0,0 +1,117 @@
+#include csrf.js
+
+const $x = (function() {
+    const send = function(method, url, options, body) {
+        if(options === undefined)
+            options = {};
+        else if(typeof options !== 'object')
+            throw 'options must be undefined or an object';
+
+        Object.freeze(options);
+
+        const xhr = new XMLHttpRequest;
+        const requestHeaders = new Map;
+
+        if('headers' in options && typeof options.headers === 'object')
+            for(const name in options.headers)
+                if(options.headers.hasOwnProperty(name))
+                    requestHeaders.set(name.toLowerCase(), options.headers[name]);
+
+        if(options.csrf)
+            requestHeaders.set('x-csrf-token', MszCSRF.token);
+
+        if(typeof options.download === 'function') {
+            xhr.onloadstart = ev => options.download(ev);
+            xhr.onprogress = ev => options.download(ev);
+            xhr.onloadend = ev => options.download(ev);
+        }
+
+        if(typeof options.upload === 'function') {
+            xhr.upload.onloadstart = ev => options.upload(ev);
+            xhr.upload.onprogress = ev => options.upload(ev);
+            xhr.upload.onloadend = ev => options.upload(ev);
+        }
+
+        if(options.authed)
+            xhr.withCredentials = true;
+
+        if(typeof options.timeout === 'number')
+            xhr.timeout = options.timeout;
+
+        if(typeof options.type === 'string')
+            xhr.responseType = options.type;
+
+        if(typeof options.abort === 'function')
+            options.abort(() => xhr.abort());
+
+        if(typeof options.xhr === 'function')
+            options.xhr(() => xhr);
+
+        if(typeof body === 'object') {
+            if(body instanceof URLSearchParams) {
+                requestHeaders.set('content-type', 'application/x-www-form-urlencoded');
+            } else if(body instanceof FormData) {
+                // content-type is implicitly set
+            } else if(body instanceof Blob || body instanceof ArrayBuffer || body instanceof DataView) {
+                if(!requestHeaders.has('content-type'))
+                    requestHeaders.set('content-type', 'application/octet-stream');
+            } else if(!requestHeaders.has('content-type')) {
+                const bodyParts = [];
+                for(const name in body)
+                    if(body.hasOwnProperty(name))
+                        bodyParts.push(encodeURIComponent(name) + '=' + encodeURIComponent(body[name]));
+                body = bodyParts.join('&');
+                requestHeaders.set('content-type', 'application/x-www-form-urlencoded');
+            }
+        }
+
+        return new Promise((resolve, reject) => {
+            xhr.onload = ev => {
+                const headers = (headersString => {
+                    const headers = new Map;
+
+                    const raw = headersString.trim().split(/[\r\n]+/);
+                    for(const name in raw)
+                        if(raw.hasOwnProperty(name)) {
+                            const parts = raw[name].split(': ');
+                            headers.set(parts.shift(), parts.join(': '));
+                        }
+
+                    return headers;
+                })(xhr.getAllResponseHeaders());
+
+                if(options.csrf && headers.has('x-csrf-token'))
+                    MszCSRF.token = headers.get('x-csrf-token');
+
+                resolve({
+                    get ev() { return ev; },
+                    get xhr() { return xhr; },
+
+                    get status() { return xhr.status; },
+                    get headers() { return headers; },
+                    get body() { return xhr.response; },
+                    get text() { return xhr.responseText; },
+                });
+            };
+
+            xhr.onerror = ev => reject({
+                xhr: xhr,
+                ev: ev,
+            });
+
+            xhr.open(method, url);
+            for(const [name, value] of requestHeaders)
+                xhr.setRequestHeader(name, value);
+            xhr.send(body);
+        });
+    };
+
+    return {
+        send: send,
+        get: (url, options, body) => send('GET', url, options, body),
+        post: (url, options, body) => send('POST', url, options, body),
+        delete: (url, options, body) => send('DELETE', url, options, body),
+        patch: (url, options, body) => send('PATCH', url, options, body),
+        put: (url, options, body) => send('PUT', url, options, body),
+    };
+})();
diff --git a/build.js b/build.js
index 8965136a..35fe521b 100644
--- a/build.js
+++ b/build.js
@@ -18,12 +18,14 @@ const fs = require('fs');
     const tasks = {
         js: [
             { source: 'misuzu.js', target: '/assets', name: 'misuzu.{hash}.js', },
+            { source: 'oauth2.js', target: '/assets', name: 'oauth2.{hash}.js', },
             { source: 'redir-bsky.js', target: '/assets', name: 'redir-bsky.{hash}.js', },
             { source: 'redir-fedi.js', target: '/assets', name: 'redir-fedi.{hash}.js', },
         ],
         css: [
             { source: 'errors.css', target: '/', name: 'errors.css', },
             { source: 'misuzu.css', target: '/assets', name: 'misuzu.{hash}.css', },
+            { source: 'oauth2.css', target: '/assets', name: 'oauth2.{hash}.css', },
         ],
         twig: [
             { source: 'errors/400', target: '/', name: 'error-400.html', },
diff --git a/config/config.example.cfg b/config/config.example.cfg
index 21422f97..e581553b 100644
--- a/config/config.example.cfg
+++ b/config/config.example.cfg
@@ -7,5 +7,5 @@ database:dsn mariadb://<user>:<pass>@<host>/<name>?charset=utf8mb4
 ;sentry:tracesRate   1.0
 ;sentry:profilesRate 1.0
 
-domain:localhost main redirect
+domain:localhost main redirect id
 domain:localhost:redirect:path /go
diff --git a/database/2025_02_01_181944_create_apps_tables.php b/database/2025_02_01_181944_create_apps_tables.php
new file mode 100644
index 00000000..c70baa0d
--- /dev/null
+++ b/database/2025_02_01_181944_create_apps_tables.php
@@ -0,0 +1,54 @@
+<?php
+use Index\Db\DbConnection;
+use Index\Db\Migration\DbMigration;
+
+final class CreateAppsTables_20250201_181944 implements DbMigration {
+    public function migrate(DbConnection $conn): void {
+        $conn->execute(<<<SQL
+            CREATE TABLE msz_apps (
+                app_id               INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+                user_id              INT(10) UNSIGNED     NULL DEFAULT NULL,
+                app_name             VARCHAR(64)      NOT NULL COLLATE 'utf8mb4_unicode_520_ci',
+                app_summary          VARCHAR(255)     NOT NULL COLLATE 'utf8mb4_bin',
+                app_website          VARCHAR(255)     NOT NULL COLLATE 'utf8mb4_bin',
+                app_type             ENUM('public','confidential','trusted') NOT NULL COLLATE 'ascii_general_ci',
+                app_access_lifetime  INT(10) UNSIGNED     NULL DEFAULT NULL,
+                app_refresh_lifetime INT(10) UNSIGNED     NULL DEFAULT NULL,
+                app_client_id        CHAR(20)         NOT NULL COLLATE 'ascii_bin',
+                app_client_secret    VARCHAR(255)     NOT NULL COLLATE 'ascii_bin',
+                app_created          TIMESTAMP        NOT NULL DEFAULT current_timestamp(),
+                app_updated          TIMESTAMP        NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
+                app_deleted          TIMESTAMP            NULL DEFAULT NULL,
+                PRIMARY KEY (app_id),
+                UNIQUE INDEX apps_client_id_unique (app_client_id),
+                UNIQUE INDEX apps_name_unique (app_name),
+                       INDEX apps_user_foreign (user_id),
+                       INDEX apps_created_index (app_created),
+                       INDEX apps_deleted_index (app_deleted),
+                CONSTRAINT apps_user_foreign
+                    FOREIGN KEY (user_id)
+                    REFERENCES msz_users (user_id)
+                    ON UPDATE CASCADE
+                    ON DELETE CASCADE
+            ) COLLATE='utf8mb4_bin' ENGINE=InnoDB;
+        SQL);
+
+        $conn->execute(<<<SQL
+            CREATE TABLE msz_apps_uris (
+                uri_id      INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+                app_id      INT(10) UNSIGNED NOT NULL,
+                uri_string  VARCHAR(255)     NOT NULL COLLATE 'ascii_bin',
+                uri_created TIMESTAMP        NOT NULL DEFAULT current_timestamp(),
+                PRIMARY KEY (uri_id),
+                INDEX apps_uris_app_foreign (app_id),
+                INDEX apps_uris_lookup_index (uri_id, uri_string),
+                INDEX apps_uri_created_index (uri_created),
+                CONSTRAINT apps_uris_app_foreign
+                    FOREIGN KEY (app_id)
+                    REFERENCES msz_apps (app_id)
+                    ON UPDATE CASCADE
+                    ON DELETE CASCADE
+            ) COLLATE='utf8mb4_bin' ENGINE=InnoDB;
+        SQL);
+    }
+}
diff --git a/database/2025_02_01_182753_create_scopes_tables.php b/database/2025_02_01_182753_create_scopes_tables.php
new file mode 100644
index 00000000..5440c822
--- /dev/null
+++ b/database/2025_02_01_182753_create_scopes_tables.php
@@ -0,0 +1,43 @@
+<?php
+use Index\Db\DbConnection;
+use Index\Db\Migration\DbMigration;
+
+final class CreateScopesTables_20250201_182753 implements DbMigration {
+    public function migrate(DbConnection $conn): void {
+        $conn->execute(<<<SQL
+            CREATE TABLE msz_scopes (
+                scope_id         INT(10) UNSIGNED    NOT NULL AUTO_INCREMENT,
+                scope_string     VARCHAR(50)         NOT NULL COLLATE 'ascii_bin',
+                scope_restricted TINYINT(3) UNSIGNED NOT NULL,
+                scope_summary    VARCHAR(255)        NOT NULL DEFAULT '' COLLATE 'utf8mb4_unicode_520_ci',
+                scope_created    TIMESTAMP           NOT NULL DEFAULT current_timestamp(),
+                scope_deprecated TIMESTAMP               NULL DEFAULT NULL,
+                PRIMARY KEY (scope_id),
+                UNIQUE INDEX scopes_string_unique (scope_string),
+                       INDEX scopes_created_index (scope_created),
+                       INDEX scopes_deprecated_index (scope_deprecated)
+            ) COLLATE=utf8mb4_bin ENGINE=InnoDB;
+        SQL);
+
+        $conn->execute(<<<SQL
+            CREATE TABLE msz_apps_scopes (
+                app_id        INT(10) UNSIGNED    NOT NULL,
+                scope_id      INT(10) UNSIGNED    NOT NULL,
+                scope_allowed TINYINT(3) UNSIGNED NOT NULL,
+                PRIMARY KEY (app_id, scope_id),
+                INDEX apps_scopes_app_foreign (app_id),
+                INDEX apps_scopes_scope_foreign (scope_id),
+                CONSTRAINT apps_scopes_app_foreign
+                    FOREIGN KEY (app_id)
+                    REFERENCES msz_apps (app_id)
+                    ON UPDATE CASCADE
+                    ON DELETE CASCADE,
+                CONSTRAINT apps_scopes_scope_foreign
+                    FOREIGN KEY (scope_id)
+                    REFERENCES msz_scopes (scope_id)
+                    ON UPDATE CASCADE
+                    ON DELETE CASCADE
+            ) COLLATE=utf8mb4_bin ENGINE=InnoDB;
+        SQL);
+    }
+}
diff --git a/database/2025_02_01_183150_create_oauth_tables.php b/database/2025_02_01_183150_create_oauth_tables.php
new file mode 100644
index 00000000..0a212a59
--- /dev/null
+++ b/database/2025_02_01_183150_create_oauth_tables.php
@@ -0,0 +1,136 @@
+<?php
+use Index\Db\DbConnection;
+use Index\Db\Migration\DbMigration;
+
+final class CreateOauthTables_20250201_183150 implements DbMigration {
+    public function migrate(DbConnection $conn): void {
+        $conn->execute(<<<SQL
+            CREATE TABLE msz_oauth2_authorise (
+                auth_id               INT(10) UNSIGNED                    NOT NULL AUTO_INCREMENT,
+                app_id                INT(10) UNSIGNED                    NOT NULL,
+                user_id               INT(10) UNSIGNED                    NOT NULL,
+                uri_id                INT(10) UNSIGNED                    NOT NULL,
+                auth_challenge_code   VARCHAR(128)                        NOT NULL                   COLLATE 'ascii_bin',
+                auth_challenge_method ENUM('plain','S256')                NOT NULL DEFAULT 'plain'   COLLATE 'ascii_bin',
+                auth_scope            TEXT                                NOT NULL                   COLLATE 'ascii_bin',
+                auth_code             CHAR(60)                            NOT NULL                   COLLATE 'ascii_bin',
+                auth_created          TIMESTAMP                           NOT NULL DEFAULT current_timestamp(),
+                auth_expires          TIMESTAMP                           NOT NULL DEFAULT (current_timestamp() + interval 10 minute),
+                PRIMARY KEY (auth_id),
+                UNIQUE INDEX oauth2_authorise_code_unique (auth_code),
+                       INDEX oauth2_authorise_app_foreign (app_id),
+                       INDEX oauth2_authorise_uri_foreign (uri_id),
+                       INDEX oauth2_authorise_user_foreign (user_id),
+                       INDEX oauth2_authorise_expires_index (auth_expires),
+                CONSTRAINT oauth2_authorise_app_foreign
+                    FOREIGN KEY (app_id)
+                    REFERENCES msz_apps (app_id)
+                    ON UPDATE CASCADE
+                    ON DELETE CASCADE,
+                CONSTRAINT oauth2_authorise_uri_foreign
+                    FOREIGN KEY (uri_id)
+                    REFERENCES msz_apps_uris (uri_id)
+                    ON UPDATE CASCADE
+                    ON DELETE CASCADE,
+                CONSTRAINT oauth2_authorise_user_foreign
+                    FOREIGN KEY (user_id)
+                    REFERENCES msz_users (user_id)
+                    ON UPDATE CASCADE
+                    ON DELETE CASCADE
+            ) COLLATE='utf8mb4_bin' ENGINE=InnoDB;
+        SQL);
+
+        $conn->execute(<<<SQL
+            CREATE TABLE msz_oauth2_device (
+                dev_id        INT(10) UNSIGNED                    NOT NULL AUTO_INCREMENT,
+                app_id        INT(10) UNSIGNED                    NOT NULL,
+                user_id       INT(10) UNSIGNED                        NULL DEFAULT NULL,
+                dev_code      CHAR(60)                            NOT NULL                   COLLATE 'ascii_bin',
+                dev_user_code CHAR(9)                             NOT NULL                   COLLATE 'ascii_general_ci',
+                dev_interval  TINYINT(3) UNSIGNED                 NOT NULL DEFAULT '5',
+                dev_polled    TIMESTAMP                           NOT NULL DEFAULT current_timestamp(),
+                dev_scope     TEXT                                NOT NULL                   COLLATE 'ascii_bin',
+                dev_approval  ENUM('pending','approved','denied') NOT NULL DEFAULT 'pending' COLLATE 'ascii_general_ci',
+                dev_created   TIMESTAMP                           NOT NULL DEFAULT current_timestamp(),
+                dev_expires   TIMESTAMP                           NOT NULL DEFAULT (current_timestamp() + interval 10 minute),
+                PRIMARY KEY (dev_id),
+                UNIQUE INDEX oauth2_device_user_code_unique (dev_user_code),
+                UNIQUE INDEX oauth2_device_code_unique (dev_code),
+                       INDEX oauth2_device_expires_index (dev_expires),
+                       INDEX oauth2_device_app_foreign (app_id),
+                       INDEX oauth2_device_user_foreign (user_id),
+                CONSTRAINT oauth2_device_app_foreign
+                    FOREIGN KEY (app_id)
+                    REFERENCES msz_apps (app_id)
+                    ON UPDATE CASCADE
+                    ON DELETE CASCADE,
+                CONSTRAINT oauth2_device_user_foreign
+                    FOREIGN KEY (user_id)
+                    REFERENCES msz_users (user_id)
+                    ON UPDATE CASCADE
+                    ON DELETE CASCADE
+            ) COLLATE='utf8mb4_bin' ENGINE=InnoDB;
+        SQL);
+
+        $conn->execute(<<<SQL
+            CREATE TABLE msz_oauth2_access (
+                acc_id      INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+                app_id      INT(10) UNSIGNED NOT NULL,
+                user_id     INT(10) UNSIGNED     NULL DEFAULT NULL,
+                acc_token   VARCHAR(255)     NOT NULL COLLATE 'ascii_bin',
+                acc_scope   TEXT             NOT NULL COLLATE 'ascii_bin',
+                acc_created TIMESTAMP        NOT NULL DEFAULT current_timestamp(),
+                acc_expires TIMESTAMP        NOT NULL DEFAULT (current_timestamp() + interval 1 hour),
+                PRIMARY KEY (acc_id),
+                UNIQUE INDEX oauth2_access_token_unique (acc_token),
+                       INDEX oauth2_access_user_foreign (user_id),
+                       INDEX oauth2_access_app_foreign (app_id),
+                       INDEX oauth2_access_expires_index (acc_expires),
+                CONSTRAINT oauth2_access_app_foreign
+                    FOREIGN KEY (app_id)
+                    REFERENCES msz_apps (app_id)
+                    ON UPDATE CASCADE
+                    ON DELETE CASCADE,
+                CONSTRAINT oauth2_access_user_foreign
+                    FOREIGN KEY (user_id)
+                    REFERENCES msz_users (user_id)
+                    ON UPDATE CASCADE
+                    ON DELETE CASCADE
+            ) COLLATE='utf8mb4_bin' ENGINE=InnoDB;
+        SQL);
+
+        $conn->execute(<<<SQL
+            CREATE TABLE msz_oauth2_refresh (
+                ref_id      INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
+                app_id      INT(10) UNSIGNED NOT NULL,
+                user_id     INT(10) UNSIGNED     NULL DEFAULT NULL,
+                acc_id      INT(10) UNSIGNED     NULL DEFAULT NULL,
+                ref_token   VARCHAR(255)     NOT NULL COLLATE 'ascii_bin',
+                ref_scope   TEXT             NOT NULL COLLATE 'ascii_bin',
+                ref_created TIMESTAMP        NOT NULL DEFAULT current_timestamp(),
+                ref_expires TIMESTAMP        NOT NULL DEFAULT (current_timestamp() + interval 1 month),
+                PRIMARY KEY (ref_id),
+                UNIQUE INDEX oauth2_refresh_token_unique (ref_token),
+                UNIQUE INDEX oauth2_refresh_access_foreign (acc_id),
+                       INDEX oauth2_refresh_expires_index (ref_expires),
+                       INDEX oauth2_refresh_app_foreign (app_id),
+                       INDEX oauth2_refresh_user_foreign (user_id),
+                CONSTRAINT oauth2_refresh_access_foreign
+                    FOREIGN KEY (acc_id)
+                    REFERENCES msz_oauth2_access (acc_id)
+                    ON UPDATE CASCADE
+                    ON DELETE SET NULL,
+                CONSTRAINT oauth2_refresh_app_foreign
+                    FOREIGN KEY (app_id)
+                    REFERENCES msz_apps (app_id)
+                    ON UPDATE CASCADE
+                    ON DELETE CASCADE,
+                CONSTRAINT oauth2_refresh_user_foreign
+                    FOREIGN KEY (user_id)
+                    REFERENCES msz_users (user_id)
+                    ON UPDATE CASCADE
+                    ON DELETE CASCADE
+            ) COLLATE='utf8mb4_bin' ENGINE=InnoDB;
+        SQL);
+    }
+}
diff --git a/public-legacy/auth/login.php b/public-legacy/auth/login.php
index c6928875..35c319a0 100644
--- a/public-legacy/auth/login.php
+++ b/public-legacy/auth/login.php
@@ -7,7 +7,7 @@ use Misuzu\Auth\AuthTokenCookie;
 if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
     die('Script must be called through the Misuzu route dispatcher.');
 
-if($msz->authInfo->isLoggedIn) {
+if($msz->authInfo->loggedIn) {
     Tools::redirect($msz->urls->format('index'));
     return;
 }
diff --git a/public-legacy/auth/logout.php b/public-legacy/auth/logout.php
index fcb7f314..8df0ad2c 100644
--- a/public-legacy/auth/logout.php
+++ b/public-legacy/auth/logout.php
@@ -6,7 +6,7 @@ use Misuzu\Auth\AuthTokenCookie;
 if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
     die('Script must be called through the Misuzu route dispatcher.');
 
-if($msz->authInfo->isLoggedIn) {
+if($msz->authInfo->loggedIn) {
     if(!CSRF::validateRequest()) {
         Template::render('auth.logout');
         return;
diff --git a/public-legacy/auth/password.php b/public-legacy/auth/password.php
index 6f32afe2..23b9bae7 100644
--- a/public-legacy/auth/password.php
+++ b/public-legacy/auth/password.php
@@ -7,7 +7,7 @@ use Misuzu\Users\User;
 if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
     die('Script must be called through the Misuzu route dispatcher.');
 
-if($msz->authInfo->isLoggedIn) {
+if($msz->authInfo->loggedIn) {
     Tools::redirect($msz->urls->format('settings-account'));
     return;
 }
diff --git a/public-legacy/auth/register.php b/public-legacy/auth/register.php
index 618479b6..b273db39 100644
--- a/public-legacy/auth/register.php
+++ b/public-legacy/auth/register.php
@@ -7,7 +7,7 @@ use Misuzu\Users\User;
 if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
     die('Script must be called through the Misuzu route dispatcher.');
 
-if($msz->authInfo->isLoggedIn) {
+if($msz->authInfo->loggedIn) {
     Tools::redirect($msz->urls->format('index'));
     return;
 }
diff --git a/public-legacy/auth/twofactor.php b/public-legacy/auth/twofactor.php
index c27298b7..b571b399 100644
--- a/public-legacy/auth/twofactor.php
+++ b/public-legacy/auth/twofactor.php
@@ -8,7 +8,7 @@ use Misuzu\Auth\AuthTokenCookie;
 if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
     die('Script must be called through the Misuzu route dispatcher.');
 
-if($msz->authInfo->isLoggedIn) {
+if($msz->authInfo->loggedIn) {
     Tools::redirect($msz->urls->format('index'));
     return;
 }
diff --git a/public-legacy/comments.php b/public-legacy/comments.php
index 7d0dbfed..323c2ae7 100644
--- a/public-legacy/comments.php
+++ b/public-legacy/comments.php
@@ -15,7 +15,7 @@ if(!Tools::isLocalURL($redirect))
 if(!CSRF::validateRequest())
     Template::displayInfo("Couldn't verify this request, please refresh the page and try again.", 403);
 
-if(!$msz->authInfo->isLoggedIn)
+if(!$msz->authInfo->loggedIn)
     Template::displayInfo('You must be logged in to manage comments.', 403);
 
 if($msz->usersCtx->hasActiveBan($msz->authInfo->userInfo))
diff --git a/public-legacy/forum/posting.php b/public-legacy/forum/posting.php
index 55528df0..410806a0 100644
--- a/public-legacy/forum/posting.php
+++ b/public-legacy/forum/posting.php
@@ -11,7 +11,7 @@ use Carbon\CarbonImmutable;
 if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
     die('Script must be called through the Misuzu route dispatcher.');
 
-if(!$msz->authInfo->isLoggedIn)
+if(!$msz->authInfo->loggedIn)
     Template::throwError(401);
 
 $currentUser = $msz->authInfo->userInfo;
diff --git a/public-legacy/manage/users/user.php b/public-legacy/manage/users/user.php
index 2d46328d..4d13272d 100644
--- a/public-legacy/manage/users/user.php
+++ b/public-legacy/manage/users/user.php
@@ -11,7 +11,7 @@ if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
     die('Script must be called through the Misuzu route dispatcher.');
 
 $viewerPerms = $msz->authInfo->getPerms('user');
-if(!$msz->authInfo->isLoggedIn)
+if(!$msz->authInfo->loggedIn)
     Template::throwError(403);
 
 $currentUser = $msz->authInfo->userInfo;
diff --git a/public-legacy/members.php b/public-legacy/members.php
index 8d84f0da..ab541612 100644
--- a/public-legacy/members.php
+++ b/public-legacy/members.php
@@ -6,7 +6,7 @@ use RuntimeException;
 if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
     die('Script must be called through the Misuzu route dispatcher.');
 
-if(!$msz->authInfo->isLoggedIn)
+if(!$msz->authInfo->loggedIn)
     Template::throwError(403);
 
 // TODO: restore forum-topics and forum-posts orderings
diff --git a/public-legacy/search.php b/public-legacy/search.php
index 79fface7..85174264 100644
--- a/public-legacy/search.php
+++ b/public-legacy/search.php
@@ -9,7 +9,7 @@ use Misuzu\Comments\CommentsCategory;
 if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
     die('Script must be called through the Misuzu route dispatcher.');
 
-if(!$msz->authInfo->isLoggedIn)
+if(!$msz->authInfo->loggedIn)
     Template::throwError(403);
 
 $searchQuery = !empty($_GET['q']) && is_string($_GET['q']) ? $_GET['q'] : '';
diff --git a/public-legacy/settings/account.php b/public-legacy/settings/account.php
index 65886690..6bde1e12 100644
--- a/public-legacy/settings/account.php
+++ b/public-legacy/settings/account.php
@@ -9,7 +9,7 @@ use chillerlan\QRCode\QROptions;
 if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
     die('Script must be called through the Misuzu route dispatcher.');
 
-if(!$msz->authInfo->isLoggedIn)
+if(!$msz->authInfo->loggedIn)
     Template::throwError(401);
 
 $errors = [];
diff --git a/public-legacy/settings/data.php b/public-legacy/settings/data.php
index 78e4cfa6..98e248cb 100644
--- a/public-legacy/settings/data.php
+++ b/public-legacy/settings/data.php
@@ -8,7 +8,7 @@ use Misuzu\Users\UserInfo;
 if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
     die('Script must be called through the Misuzu route dispatcher.');
 
-if(!$msz->authInfo->isLoggedIn)
+if(!$msz->authInfo->loggedIn)
     Template::throwError(401);
 
 $dbConn = $msz->dbConn;
diff --git a/public-legacy/settings/sessions.php b/public-legacy/settings/sessions.php
index cd722bc9..b1e65883 100644
--- a/public-legacy/settings/sessions.php
+++ b/public-legacy/settings/sessions.php
@@ -6,7 +6,7 @@ use RuntimeException;
 if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
     die('Script must be called through the Misuzu route dispatcher.');
 
-if(!$msz->authInfo->isLoggedIn)
+if(!$msz->authInfo->loggedIn)
     Template::throwError(401);
 
 $errors = [];
diff --git a/public/images/circle-check-regular.svg b/public/images/circle-check-regular.svg
new file mode 100644
index 00000000..0558987c
--- /dev/null
+++ b/public/images/circle-check-regular.svg
@@ -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 48a208 208 0 1 1 0 416 208 208 0 1 1 0-416zm0 464A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-111 111-47-47c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l64 64c9.4 9.4 24.6 9.4 33.9 0L369 209z"/></svg>
\ No newline at end of file
diff --git a/public/images/circle-check-solid.svg b/public/images/circle-check-solid.svg
new file mode 100644
index 00000000..a5613987
--- /dev/null
+++ b/public/images/circle-check-solid.svg
@@ -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 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/></svg>
\ No newline at end of file
diff --git a/public/images/circle-exclamation-solid.svg b/public/images/circle-exclamation-solid.svg
new file mode 100644
index 00000000..99a68d73
--- /dev/null
+++ b/public/images/circle-exclamation-solid.svg
@@ -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 512zm0-384c13.3 0 24 10.7 24 24l0 112c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-112c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"/></svg>
\ No newline at end of file
diff --git a/public/images/circle-question-solid.svg b/public/images/circle-question-solid.svg
new file mode 100644
index 00000000..3cc83fd0
--- /dev/null
+++ b/public/images/circle-question-solid.svg
@@ -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>
\ No newline at end of file
diff --git a/public/images/circle-regular.svg b/public/images/circle-regular.svg
new file mode 100644
index 00000000..bd6be279
--- /dev/null
+++ b/public/images/circle-regular.svg
@@ -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="M464 256A208 208 0 1 0 48 256a208 208 0 1 0 416 0zM0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256z"/></svg>
\ No newline at end of file
diff --git a/public/images/circle-xmark-solid.svg b/public/images/circle-xmark-solid.svg
new file mode 100644
index 00000000..083c430a
--- /dev/null
+++ b/public/images/circle-xmark-solid.svg
@@ -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 512zM175 175c9.4-9.4 24.6-9.4 33.9 0l47 47 47-47c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-47 47 47 47c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-47-47-47 47c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l47-47-47-47c-9.4-9.4-9.4-24.6 0-33.9z"/></svg>
\ No newline at end of file
diff --git a/public/images/ellipsis-solid.svg b/public/images/ellipsis-solid.svg
new file mode 100644
index 00000000..7283ce39
--- /dev/null
+++ b/public/images/ellipsis-solid.svg
@@ -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>
\ No newline at end of file
diff --git a/public/images/exclamation-solid.svg b/public/images/exclamation-solid.svg
new file mode 100644
index 00000000..53d7d5b8
--- /dev/null
+++ b/public/images/exclamation-solid.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M96 64c0-17.7-14.3-32-32-32S32 46.3 32 64l0 256c0 17.7 14.3 32 32 32s32-14.3 32-32L96 64zM64 480a40 40 0 1 0 0-80 40 40 0 1 0 0 80z"/></svg>
\ No newline at end of file
diff --git a/public/images/flashii.svg b/public/images/flashii.svg
new file mode 100644
index 00000000..24f95030
--- /dev/null
+++ b/public/images/flashii.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 1000 1000" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><g id="g285"><path id="rect18" d="M912.855,608.974l-86.145,321.498l-57.813,-15.491l86.145,-321.498l57.813,15.491Z"/><path id="rect16" d="M854.364,442.816l-86.145,321.498l-57.813,-15.49l86.145,-321.499l57.813,15.491Z"/><path id="rect14" d="M772.856,362.559l-86.145,321.498l-57.813,-15.491l86.145,-321.498l57.813,15.491Z"/><path id="rect12" d="M676.004,339.569l-86.145,321.498l-57.813,-15.491l86.145,-321.498l57.813,15.491Z"/><path id="rect10" d="M571.479,345.212l-86.145,321.498l-57.813,-15.491l86.145,-321.498l57.813,15.491Z"/><path id="rect8" d="M466.954,350.855l-86.145,321.498l-57.813,-15.491l86.146,-321.498l57.812,15.491Z"/><path id="rect6" d="M370.102,327.865l-86.145,321.498l-57.813,-15.491l86.145,-321.498l57.813,15.491Z"/><path id="rect4" d="M288.594,247.608l-86.145,321.498l-57.813,-15.491l86.145,-321.498l57.813,15.491Z"/><path id="rect2" d="M230.103,81.45l-86.145,321.498l-57.813,-15.49l86.145,-321.499l57.813,15.491Z"/></g></svg>
\ No newline at end of file
diff --git a/public/images/globe-solid.svg b/public/images/globe-solid.svg
new file mode 100644
index 00000000..c2af6c6a
--- /dev/null
+++ b/public/images/globe-solid.svg
@@ -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="M352 256c0 22.2-1.2 43.6-3.3 64l-185.3 0c-2.2-20.4-3.3-41.8-3.3-64s1.2-43.6 3.3-64l185.3 0c2.2 20.4 3.3 41.8 3.3 64zm28.8-64l123.1 0c5.3 20.5 8.1 41.9 8.1 64s-2.8 43.5-8.1 64l-123.1 0c2.1-20.6 3.2-42 3.2-64s-1.1-43.4-3.2-64zm112.6-32l-116.7 0c-10-63.9-29.8-117.4-55.3-151.6c78.3 20.7 142 77.5 171.9 151.6zm-149.1 0l-176.6 0c6.1-36.4 15.5-68.6 27-94.7c10.5-23.6 22.2-40.7 33.5-51.5C239.4 3.2 248.7 0 256 0s16.6 3.2 27.8 13.8c11.3 10.8 23 27.9 33.5 51.5c11.6 26 20.9 58.2 27 94.7zm-209 0L18.6 160C48.6 85.9 112.2 29.1 190.6 8.4C165.1 42.6 145.3 96.1 135.3 160zM8.1 192l123.1 0c-2.1 20.6-3.2 42-3.2 64s1.1 43.4 3.2 64L8.1 320C2.8 299.5 0 278.1 0 256s2.8-43.5 8.1-64zM194.7 446.6c-11.6-26-20.9-58.2-27-94.6l176.6 0c-6.1 36.4-15.5 68.6-27 94.6c-10.5 23.6-22.2 40.7-33.5 51.5C272.6 508.8 263.3 512 256 512s-16.6-3.2-27.8-13.8c-11.3-10.8-23-27.9-33.5-51.5zM135.3 352c10 63.9 29.8 117.4 55.3 151.6C112.2 482.9 48.6 426.1 18.6 352l116.7 0zm358.1 0c-30 74.1-93.6 130.9-171.9 151.6c25.5-34.2 45.2-87.7 55.3-151.6l116.7 0z"/></svg>
\ No newline at end of file
diff --git a/public/images/mobile-screen-solid.svg b/public/images/mobile-screen-solid.svg
new file mode 100644
index 00000000..94f8033e
--- /dev/null
+++ b/public/images/mobile-screen-solid.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M16 64C16 28.7 44.7 0 80 0L304 0c35.3 0 64 28.7 64 64l0 384c0 35.3-28.7 64-64 64L80 512c-35.3 0-64-28.7-64-64L16 64zM144 448c0 8.8 7.2 16 16 16l64 0c8.8 0 16-7.2 16-16s-7.2-16-16-16l-64 0c-8.8 0-16 7.2-16 16zM304 64L80 64l0 320 224 0 0-320z"/></svg>
\ No newline at end of file
diff --git a/public/images/user-lock-solid.svg b/public/images/user-lock-solid.svg
new file mode 100644
index 00000000..74f93a17
--- /dev/null
+++ b/public/images/user-lock-solid.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M224 256A128 128 0 1 0 224 0a128 128 0 1 0 0 256zm-45.7 48C79.8 304 0 383.8 0 482.3C0 498.7 13.3 512 29.7 512l362.8 0c-5.4-9.4-8.6-20.3-8.6-32l0-128c0-2.1 .1-4.2 .3-6.3c-31-26-71-41.7-114.6-41.7l-91.4 0zM528 240c17.7 0 32 14.3 32 32l0 48-64 0 0-48c0-17.7 14.3-32 32-32zm-80 32l0 48c-17.7 0-32 14.3-32 32l0 128c0 17.7 14.3 32 32 32l160 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32l0-48c0-44.2-35.8-80-80-80s-80 35.8-80 80z"/></svg>
\ No newline at end of file
diff --git a/public/index.php b/public/index.php
index bc29cb01..120453ed 100644
--- a/public/index.php
+++ b/public/index.php
@@ -121,7 +121,7 @@ $msz->authInfo->setInfo($tokenInfo, $userInfo, $sessionInfo, $userInfoReal);
 
 CSRF::init(
     $msz->config->getString('csrf.secret', 'soup'),
-    ($msz->authInfo->isLoggedIn ? $sessionInfo->token : $remoteAddr)
+    ($msz->authInfo->loggedIn ? $sessionInfo->token : $remoteAddr)
 );
 
 // order for these two currently matters i think: it shouldn't.
diff --git a/src/ATProto/XrpcClient.php b/src/ATProto/XrpcClient.php
index 1e9f20f6..594b8272 100644
--- a/src/ATProto/XrpcClient.php
+++ b/src/ATProto/XrpcClient.php
@@ -33,6 +33,12 @@ class XrpcClient {
         curl_close($this->handle);
     }
 
+    /**
+     * @param array<string, scalar> $headers
+     * @param array<string, scalar> $params
+     * @param mixed[]|object|string|null $data
+     * @return object{ headers: array<string, string>, data: mixed }
+     */
     private function request(
         string $method,
         string $nsid,
@@ -135,6 +141,11 @@ class XrpcClient {
         ];
     }
 
+    /**
+     * @param array<string, scalar> $headers
+     * @param array<string, scalar> $params
+     * @return object{ headers: array<string, string>, data: mixed }
+     */
     public function query(
         string $nsid,
         array $headers = [],
@@ -143,6 +154,12 @@ class XrpcClient {
         return $this->request('GET', $nsid, $headers, $params, null);
     }
 
+    /**
+     * @param array<string, scalar> $headers
+     * @param array<string, scalar> $params
+     * @param mixed[]|object|string|null $data
+     * @return object{ headers: array<string, string>, data: mixed }
+     */
     public function call(
         string $nsid,
         array $headers = [],
diff --git a/src/Apps/AppInfo.php b/src/Apps/AppInfo.php
new file mode 100644
index 00000000..6907d5de
--- /dev/null
+++ b/src/Apps/AppInfo.php
@@ -0,0 +1,87 @@
+<?php
+namespace Misuzu\Apps;
+
+use Carbon\CarbonImmutable;
+use Index\Db\DbResult;
+
+class AppInfo {
+    public function __construct(
+        public private(set) string $id,
+        public private(set) ?string $userId,
+        public private(set) string $name,
+        public private(set) string $summary,
+        public private(set) string $website,
+        public private(set) AppType $type,
+        public private(set) ?int $accessTokenLifetime,
+        public private(set) ?int $refreshTokenLifetime,
+        public private(set) string $clientId,
+        #[\SensitiveParameter] private string $clientSecret,
+        public private(set) int $createdTime,
+        public private(set) int $updatedTime,
+        public private(set) ?int $deletedTime
+    ) {}
+
+    public static function fromResult(DbResult $result): AppInfo {
+        return new AppInfo(
+            id: $result->getString(0),
+            userId: $result->getStringOrNull(1),
+            name: $result->getString(2),
+            summary: $result->getString(3),
+            website: $result->getString(4),
+            type: AppType::tryFrom($result->getString(5)) ?? AppType::Public,
+            accessTokenLifetime: $result->getIntegerOrNull(6),
+            refreshTokenLifetime: $result->getIntegerOrNull(7),
+            clientId: $result->getString(8),
+            clientSecret: $result->getString(9),
+            createdTime: $result->getInteger(10),
+            updatedTime: $result->getInteger(11),
+            deletedTime: $result->getIntegerOrNull(12),
+        );
+    }
+
+    public string $websiteForDisplay {
+        get {
+            $website = $this->website;
+            if(str_starts_with($website, 'https://'))
+                $website = substr($website, 8);
+
+            return rtrim($website, '/');
+        }
+    }
+
+    public bool $confidential {
+        get => $this->type === AppType::Confidential || $this->type === AppType::Trusted;
+    }
+
+    public bool $trusted {
+        get => $this->type === AppType::Trusted;
+    }
+
+    public bool $issueRefreshToken {
+        get => $this->refreshTokenLifetime === null ? $this->confidential : $this->refreshTokenLifetime > 0;
+    }
+
+    public function verifyClientSecret(#[\SensitiveParameter] string $input): bool {
+        return $this->confidential && password_verify($input, $this->clientSecret);
+    }
+
+    public CarbonImmutable $createdAt {
+        get => CarbonImmutable::createFromTimestampUTC($this->createdTime);
+    }
+
+    public bool $updated {
+        get => $this->updatedTime > $this->createdTime;
+    }
+
+    public CarbonImmutable $updatedAt {
+        get => CarbonImmutable::createFromTimestampUTC($this->updatedTime);
+    }
+
+    public bool $deleted {
+        get => $this->deletedTime !== null;
+    }
+
+    public ?CarbonImmutable $deletedAt {
+        get => $this->deletedTime === null ? null : CarbonImmutable::createFromTimestampUTC($this->deletedTime);
+    }
+}
diff --git a/src/Apps/AppScopesInfo.php b/src/Apps/AppScopesInfo.php
new file mode 100644
index 00000000..dec81923
--- /dev/null
+++ b/src/Apps/AppScopesInfo.php
@@ -0,0 +1,23 @@
+<?php
+namespace Misuzu\Apps;
+
+class AppScopesInfo {
+    /**
+     * @param string[] $allowed
+     * @param string[] $denied
+     */
+    public function __construct(
+        public private(set) array $allowed,
+        public private(set) array $denied
+    ) {}
+
+    public function isAllowed(string $scope, bool $requiresAllow): bool {
+        if(in_array($scope, $this->denied))
+            return false;
+
+        if($requiresAllow && !in_array($scope, $this->allowed))
+            return false;
+
+        return true;
+    }
+}
diff --git a/src/Apps/AppType.php b/src/Apps/AppType.php
new file mode 100644
index 00000000..145a2f28
--- /dev/null
+++ b/src/Apps/AppType.php
@@ -0,0 +1,8 @@
+<?php
+namespace Misuzu\Apps;
+
+enum AppType: string {
+    case Public = 'public';
+    case Confidential = 'confidential';
+    case Trusted = 'trusted';
+}
diff --git a/src/Apps/AppUriInfo.php b/src/Apps/AppUriInfo.php
new file mode 100644
index 00000000..8d5bb22d
--- /dev/null
+++ b/src/Apps/AppUriInfo.php
@@ -0,0 +1,31 @@
+<?php
+namespace Misuzu\Apps;
+
+use Carbon\CarbonImmutable;
+use Index\Db\DbResult;
+
+class AppUriInfo {
+    public function __construct(
+        public private(set) string $id,
+        public private(set) string $appId,
+        public private(set) string $string,
+        public private(set) int $createdTime
+    ) {}
+
+    public static function fromResult(DbResult $result): AppUriInfo {
+        return new AppUriInfo(
+            id: $result->getString(0),
+            appId: $result->getString(1),
+            string: $result->getString(2),
+            createdTime: $result->getInteger(3)
+        );
+    }
+
+    public function compareString(string $other): int {
+        return strcmp($this->string, $other);
+    }
+
+    public CarbonImmutable $createdAt {
+        get => CarbonImmutable::createFromTimestampUTC($this->createdTime);
+    }
+}
diff --git a/src/Apps/AppsContext.php b/src/Apps/AppsContext.php
new file mode 100644
index 00000000..813942fb
--- /dev/null
+++ b/src/Apps/AppsContext.php
@@ -0,0 +1,71 @@
+<?php
+namespace Misuzu\Apps;
+
+use RuntimeException;
+use Index\Db\DbConnection;
+
+class AppsContext {
+    public private(set) AppsData $apps;
+    public private(set) ScopesData $scopes;
+
+    public function __construct(DbConnection $dbConn) {
+        $this->apps = new AppsData($dbConn);
+        $this->scopes = new ScopesData($dbConn);
+    }
+
+    /**
+     * @return array<string, string>
+     */
+    public function handleScopeString(
+        AppInfo|string $appInfo,
+        string $scope,
+        bool $allowDeprecated = false,
+        bool $sort = true,
+        bool $breakOnFail = true
+    ): array {
+        return $this->handleScopes($appInfo, explode(' ', $scope), $allowDeprecated, $sort, $breakOnFail);
+    }
+
+    /**
+     * @param string[] $strings
+     * @return array<string, string|ScopeInfo>
+     */
+    public function handleScopes(
+        AppInfo|string $appInfo,
+        array $strings,
+        bool $allowDeprecated = false,
+        bool $sort = true,
+        bool $breakOnFail = true
+    ): array {
+        if(is_string($appInfo))
+            $appInfo = $this->apps->getAppInfo(appId: $appInfo, deleted: false);
+
+        $infos = [];
+
+        foreach($strings as $string) {
+            try {
+                $scopeInfo = $this->scopes->getScopeInfo($string, ScopeInfoGetField::String);
+
+                if(!$allowDeprecated && $scopeInfo->deprecated) {
+                    $infos[$string] = 'deprecated';
+                    if($breakOnFail) break; else continue;
+                }
+
+                if(!$this->apps->isAppScopeAllowed($appInfo, $scopeInfo)) {
+                    $infos[$string] = 'restricted';
+                    if($breakOnFail) break; else continue;
+                }
+
+                $infos[$string] = $scopeInfo;
+            } catch(RuntimeException $ex) {
+                $infos[$string] = 'unknown';
+                if($breakOnFail) break; else continue;
+            }
+        }
+
+        if($sort)
+            ksort($infos, SORT_STRING);
+
+        return $infos;
+    }
+}
diff --git a/src/Apps/AppsData.php b/src/Apps/AppsData.php
new file mode 100644
index 00000000..c4e307dc
--- /dev/null
+++ b/src/Apps/AppsData.php
@@ -0,0 +1,213 @@
+<?php
+namespace Misuzu\Apps;
+
+use stdClass;
+use InvalidArgumentException;
+use RuntimeException;
+use Index\XString;
+use Index\Db\{DbConnection,DbStatementCache};
+use Misuzu\Users\UserInfo;
+
+class AppsData {
+    private DbStatementCache $cache;
+
+    public function __construct(DbConnection $dbConn) {
+        $this->cache = new DbStatementCache($dbConn);
+    }
+
+    public function getAppInfo(
+        ?string $appId = null,
+        ?string $clientId = null,
+        ?bool $deleted = null
+    ): AppInfo {
+        $hasAppId = $appId !== null;
+        $hasClientId = $clientId !== null;
+        $hasDeleted = $deleted !== null;
+
+        if($hasAppId === $hasClientId)
+            throw new InvalidArgumentException('you must specify either $appId or $clientId');
+
+        $values = [];
+        $query = <<<SQL
+            SELECT app_id, user_id, app_name, app_summary, app_website, app_type,
+                app_access_lifetime, app_refresh_lifetime, app_client_id, app_client_secret,
+                UNIX_TIMESTAMP(app_created), UNIX_TIMESTAMP(app_updated), UNIX_TIMESTAMP(app_deleted)
+            FROM msz_apps
+        SQL;
+        $query .= sprintf(' WHERE %s = ?', $hasAppId ? 'app_id' : 'app_client_id');
+        if($hasDeleted)
+            $query .= sprintf(' AND app_deleted %s NULL', $deleted ? 'IS NOT' : 'IS');
+
+        $stmt = $this->cache->get($query);
+        $stmt->nextParameter($hasAppId ? $appId : $clientId);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        if(!$result->next())
+            throw new RuntimeException('Application not found.');
+
+        return AppInfo::fromResult($result);
+    }
+
+    public const int CLIENT_ID_LENGTH = 20;
+    public const string CLIENT_SECRET_ALGO = PASSWORD_ARGON2ID;
+
+    public function createApp(
+        string $name,
+        AppType $type,
+        UserInfo|string|null $userInfo = null,
+        string $summary = '',
+        string $website = '',
+        ?string $clientId = null,
+        #[\SensitiveParameter] ?string $clientSecret = null,
+        ?int $accessLifetime = null,
+        ?int $refreshLifetime = null
+    ): AppInfo {
+        if(trim($name) === '')
+            throw new InvalidArgumentException('$name may not be empty');
+
+        if($clientId === null)
+            $clientId = XString::random(self::CLIENT_ID_LENGTH);
+        elseif(trim($clientId) === '')
+            throw new InvalidArgumentException('$clientId may not be empty');
+
+        if($accessLifetime !== null && $accessLifetime < 1)
+            throw new InvalidArgumentException('$accessLifetime must be null or greater than zero');
+        if($refreshLifetime !== null && $refreshLifetime < 0)
+            throw new InvalidArgumentException('$refreshLifetime must be null or a positive integer');
+
+        $summary = trim($summary);
+        $website = trim($website);
+        $clientSecret = $clientSecret === null ? '' : password_hash($clientSecret, self::CLIENT_SECRET_ALGO);
+
+        if($type !== AppType::Public && $clientSecret === '')
+            throw new InvalidArgumentException('$clientSecret must be specified for confidential clients');
+
+        $stmt = $this->cache->get(<<<SQL
+            INSERT INTO msz_apps (
+                user_id, app_name, app_summary, app_website, app_type,
+                app_access_lifetime, app_refresh_lifetime,
+                app_client_id, app_client_secret
+            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+        SQL);
+        $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
+        $stmt->nextParameter($name);
+        $stmt->nextParameter($summary);
+        $stmt->nextParameter($website);
+        $stmt->nextParameter($type);
+        $stmt->nextParameter($accessLifetime);
+        $stmt->nextParameter($refreshLifetime);
+        $stmt->nextParameter($clientId);
+        $stmt->nextParameter($clientSecret);
+        $stmt->execute();
+
+        return $this->getAppInfo(appId: (string)$stmt->lastInsertId);
+    }
+
+    public function countAppUris(AppInfo|string $appInfo): int {
+        $stmt = $this->cache->get('SELECT COUNT(*) FROM msz_apps_uris WHERE app_id = ?');
+        $stmt->nextParameter($appInfo instanceof AppInfo ? $appInfo->id : $appInfo);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        return $result->next() ? $result->getInteger(0) : 0;
+    }
+
+    /** @return iterable<AppUriInfo> */
+    public function getAppUriInfos(AppInfo|string $appInfo): iterable {
+        $stmt = $this->cache->get(<<<SQL
+            SELECT uri_id, app_id, uri_string, UNIX_TIMESTAMP(uri_created)
+            FROM msz_apps_uris
+            WHERE app_id = ?
+        SQL);
+        $stmt->nextParameter($appInfo instanceof AppInfo ? $appInfo->id : $appInfo);
+        $stmt->execute();
+
+        return $stmt->getResultIterator(AppUriInfo::fromResult(...));
+    }
+
+    public function getAppUriInfo(string $uriId): AppUriInfo {
+        $stmt = $this->cache->get(<<<SQL
+            SELECT uri_id, app_id, uri_string, UNIX_TIMESTAMP(uri_created)
+            FROM msz_apps_uris
+            WHERE uri_id = ?
+        SQL);
+        $stmt->nextParameter($uriId);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        if(!$result->next())
+            throw new RuntimeException('URI not found.');
+
+        return AppUriInfo::fromResult($result);
+    }
+
+    public function getAppUriId(AppInfo|string $appInfo, string $uriString): ?string {
+        $stmt = $this->cache->get('SELECT uri_id FROM msz_apps_uris WHERE app_id = ? AND uri_string = ?');
+        $stmt->nextParameter($appInfo instanceof AppInfo ? $appInfo->id : $appInfo);
+        $stmt->nextParameter($uriString);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        return $result->next() ? $result->getStringOrNull(0) : null;
+    }
+
+    public function getAppScopes(AppInfo|string $appInfo): AppScopesInfo {
+        $allowed = [];
+        $denied = [];
+
+        $stmt = $this->cache->get(<<<SQL
+            SELECT (
+                SELECT scope_string
+                FROM msz_scopes AS _s
+                WHERE _s.scope_id = _as.scope_id
+            ), scope_allowed
+            FROM msz_apps_scopes AS _as
+            WHERE app_id = ?
+        SQL);
+        $stmt->nextParameter($appInfo instanceof AppInfo ? $appInfo->id : $appInfo);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        while($result->next()) {
+            $scopeStr = $result->getString(0);
+            if($result->getBoolean(1))
+                $allowed[] = $scopeStr;
+            else
+                $denied[] = $scopeStr;
+        }
+
+        return new AppScopesInfo($allowed, $denied);
+    }
+
+    public function setAppScopeAllow(AppInfo|string $appInfo, ScopeInfo $scopeInfo, ?bool $allowed): void {
+        if($allowed !== $scopeInfo->restricted) {
+            $stmt = $this->cache->get('DELETE FROM msz_apps_scopes WHERE app_id = ? AND scope_id = ?');
+        } else {
+            $stmt = $this->cache->get('REPLACE INTO msz_apps_scopes (app_id, scope_id, scope_allowed) VALUES (?, ?, ?)');
+            $stmt->addParameter(3, $allowed ? 1 : 0);
+        }
+
+        $stmt->nextParameter($appInfo instanceof AppInfo ? $appInfo->id : $appInfo);
+        $stmt->nextParameter($scopeInfo->id);
+        $stmt->execute();
+    }
+
+    public function isAppScopeAllowed(AppInfo|string $appInfo, ScopeInfo|string $scopeInfo): bool {
+        $stmt = $this->cache->get(<<<SQL
+            SELECT ? AS _scope_id, COALESCE(
+                (SELECT scope_allowed FROM msz_apps_scopes WHERE app_id = ? AND scope_id = _scope_id),
+                (SELECT NOT scope_restricted FROM msz_scopes WHERE scope_id = _scope_id)
+            )
+        SQL);
+        $stmt->nextParameter($scopeInfo instanceof ScopeInfo ? $scopeInfo->id : $scopeInfo);
+        $stmt->nextParameter($appInfo instanceof AppInfo ? $appInfo->id : $appInfo);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        if(!$result->next())
+            throw new RuntimeException('failed to check scope ACL');
+
+        return $result->getBoolean(1);
+    }
+}
diff --git a/src/Apps/ScopeInfo.php b/src/Apps/ScopeInfo.php
new file mode 100644
index 00000000..dffea02c
--- /dev/null
+++ b/src/Apps/ScopeInfo.php
@@ -0,0 +1,39 @@
+<?php
+namespace Misuzu\Apps;
+
+use Carbon\CarbonImmutable;
+use Index\Db\DbResult;
+
+class ScopeInfo {
+    public function __construct(
+        public private(set) string $id,
+        public private(set) string $string,
+        public private(set) bool $restricted,
+        public private(set) string $summary,
+        public private(set) int $createdTime,
+        public private(set) ?int $deprecatedTime
+    ) {}
+
+    public static function fromResult(DbResult $result): ScopeInfo {
+        return new ScopeInfo(
+            id: $result->getString(0),
+            string: $result->getString(1),
+            restricted: $result->getBoolean(2),
+            summary: $result->getString(3),
+            createdTime: $result->getInteger(4),
+            deprecatedTime: $result->getIntegerOrNull(5)
+        );
+    }
+
+    public CarbonImmutable $createdAt {
+        get => CarbonImmutable::createFromTimestampUTC($this->createdTime);
+    }
+
+    public bool $deprecated {
+        get => $this->deprecatedTime !== null;
+    }
+
+    public ?CarbonImmutable $deprecatedAt {
+        get => $this->deprecatedTime === null ? null : CarbonImmutable::createFromTimestampUTC($this->deprecatedTime);
+    }
+}
diff --git a/src/Apps/ScopeInfoGetField.php b/src/Apps/ScopeInfoGetField.php
new file mode 100644
index 00000000..f6063a10
--- /dev/null
+++ b/src/Apps/ScopeInfoGetField.php
@@ -0,0 +1,7 @@
+<?php
+namespace Misuzu\Apps;
+
+enum ScopeInfoGetField {
+    case Id;
+    case String;
+}
diff --git a/src/Apps/ScopesData.php b/src/Apps/ScopesData.php
new file mode 100644
index 00000000..d03b961a
--- /dev/null
+++ b/src/Apps/ScopesData.php
@@ -0,0 +1,57 @@
+<?php
+namespace Misuzu\Apps;
+
+use InvalidArgumentException;
+use RuntimeException;
+use Index\Db\{DbConnection,DbStatementCache};
+
+class ScopesData {
+    private DbStatementCache $cache;
+
+    public function __construct(DbConnection $dbConn) {
+        $this->cache = new DbStatementCache($dbConn);
+    }
+
+    public function getScopeInfo(string $value, ScopeInfoGetField $field): ScopeInfo {
+        $stmt = $this->cache->get(sprintf(
+            <<<SQL
+                SELECT scope_id, scope_string, scope_restricted, scope_summary,
+                    UNIX_TIMESTAMP(scope_created), UNIX_TIMESTAMP(scope_deprecated)
+                FROM msz_scopes
+                WHERE %s = ?
+            SQL,
+            match($field) {
+                ScopeInfoGetField::Id => 'scope_id',
+                ScopeInfoGetField::String => 'scope_string',
+            }
+        ));
+
+        $stmt->nextParameter($value);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        if(!$result->next())
+            throw new RuntimeException('Scope not found.');
+
+        return ScopeInfo::fromResult($result);
+    }
+
+    public function createScope(string $string, bool $restricted, string $summary = ''): ScopeInfo {
+        if(trim($string) === '')
+            throw new InvalidArgumentException('$string may not be empty');
+        if(preg_match('#[^A-Za-z0-9:-]#', $string))
+            throw new InvalidArgumentException('$string contains invalid characters');
+
+        $stmt = $this->cache->get(<<<SQL
+            INSERT INTO msz_scopes (
+                scope_string, scope_restricted, scope_summary
+            ) VALUES (?, ?, ?)
+        SQL);
+        $stmt->nextParameter($string);
+        $stmt->nextParameter($restricted ? 1 : 0);
+        $stmt->nextParameter(trim($summary));
+        $stmt->execute();
+
+        return $this->getScopeInfo((string)$stmt->lastInsertId, ScopeInfoGetField::Id);
+    }
+}
diff --git a/src/AuditLog/AuditLogData.php b/src/AuditLog/AuditLogData.php
index 175fca76..59ae72a4 100644
--- a/src/AuditLog/AuditLogData.php
+++ b/src/AuditLog/AuditLogData.php
@@ -89,7 +89,7 @@ class AuditLogData {
 
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(AuditLogInfo::fromResult(...));
+        return $stmt->getResultIterator(AuditLogInfo::fromResult(...));
     }
 
     /** @param mixed[] $params */
diff --git a/src/Auth/AuthInfo.php b/src/Auth/AuthInfo.php
index f8f9771b..8c5a84ed 100644
--- a/src/Auth/AuthInfo.php
+++ b/src/Auth/AuthInfo.php
@@ -39,7 +39,7 @@ class AuthInfo {
         $this->setInfo(AuthTokenInfo::empty());
     }
 
-    public bool $isLoggedIn {
+    public bool $loggedIn {
         get => $this->userInfo !== null;
     }
 
diff --git a/src/Auth/LoginAttemptsData.php b/src/Auth/LoginAttemptsData.php
index 1a5e07b6..ce7a00f2 100644
--- a/src/Auth/LoginAttemptsData.php
+++ b/src/Auth/LoginAttemptsData.php
@@ -113,7 +113,7 @@ class LoginAttemptsData {
             $pagination->addToStatement($stmt);
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(LoginAttemptInfo::fromResult(...));
+        return $stmt->getResultIterator(LoginAttemptInfo::fromResult(...));
     }
 
     public function recordAttempt(
diff --git a/src/Auth/SessionsData.php b/src/Auth/SessionsData.php
index c0312cae..e3590b41 100644
--- a/src/Auth/SessionsData.php
+++ b/src/Auth/SessionsData.php
@@ -79,7 +79,7 @@ class SessionsData {
             $pagination->addToStatement($stmt);
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(SessionInfo::fromResult(...));
+        return $stmt->getResultIterator(SessionInfo::fromResult(...));
     }
 
     public function getSession(
diff --git a/src/Changelog/ChangelogData.php b/src/Changelog/ChangelogData.php
index eec20a12..603a2ab2 100644
--- a/src/Changelog/ChangelogData.php
+++ b/src/Changelog/ChangelogData.php
@@ -166,7 +166,7 @@ class ChangelogData {
 
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(ChangeInfo::fromResult(...));
+        return $stmt->getResultIterator(ChangeInfo::fromResult(...));
     }
 
     public function getChange(string $changeId): ChangeInfo {
diff --git a/src/Comments/CommentsData.php b/src/Comments/CommentsData.php
index ae38ab32..a98fb6a6 100644
--- a/src/Comments/CommentsData.php
+++ b/src/Comments/CommentsData.php
@@ -66,7 +66,7 @@ class CommentsData {
 
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(CommentsCategoryInfo::fromResult(...));
+        return $stmt->getResultIterator(CommentsCategoryInfo::fromResult(...));
     }
 
     public function getCategory(
@@ -317,7 +317,7 @@ class CommentsData {
             $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(fn($result) => CommentsPostInfo::fromResult($result, $includeRepliesCount, $includeVotesCount));
+        return $stmt->getResultIterator(fn($result) => CommentsPostInfo::fromResult($result, $includeRepliesCount, $includeVotesCount));
     }
 
     public function getPost(
diff --git a/src/Comments/CommentsEx.php b/src/Comments/CommentsEx.php
index 2515b320..4701744a 100644
--- a/src/Comments/CommentsEx.php
+++ b/src/Comments/CommentsEx.php
@@ -20,7 +20,7 @@ class CommentsEx {
         if(is_string($category))
             $category = $this->comments->ensureCategory($category);
 
-        $hasUser = $this->authInfo->isLoggedIn;
+        $hasUser = $this->authInfo->loggedIn;
         $info->user = $hasUser ? $this->authInfo->userInfo : null;
         $info->colour = $this->usersCtx->getUserColour($info->user);
         $info->perms = $this->authInfo->getPerms('global')->checkMany([
diff --git a/src/Counters/CountersData.php b/src/Counters/CountersData.php
index ff37ae9a..eb9c3818 100644
--- a/src/Counters/CountersData.php
+++ b/src/Counters/CountersData.php
@@ -43,7 +43,7 @@ class CountersData {
             $pagination->addToStatement($stmt);
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(CounterInfo::fromResult(...));
+        return $stmt->getResultIterator(CounterInfo::fromResult(...));
     }
 
     /**
diff --git a/src/Emoticons/EmotesData.php b/src/Emoticons/EmotesData.php
index 4e216494..7fa84ca2 100644
--- a/src/Emoticons/EmotesData.php
+++ b/src/Emoticons/EmotesData.php
@@ -66,7 +66,7 @@ class EmotesData {
             $stmt->nextParameter($minRank);
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(EmoteInfo::fromResult(...));
+        return $stmt->getResultIterator(EmoteInfo::fromResult(...));
     }
 
     private static function checkEmoteUrlInternal(string $url): string {
@@ -160,7 +160,7 @@ class EmotesData {
         $stmt->nextParameter($infoOrId);
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(EmoteStringInfo::fromResult(...));
+        return $stmt->getResultIterator(EmoteStringInfo::fromResult(...));
     }
 
     private static function checkEmoteStringInternal(string $string): string {
diff --git a/src/Forum/ForumCategoriesData.php b/src/Forum/ForumCategoriesData.php
index 9dc0bb3c..5a50218f 100644
--- a/src/Forum/ForumCategoriesData.php
+++ b/src/Forum/ForumCategoriesData.php
@@ -155,7 +155,7 @@ class ForumCategoriesData {
             $pagination->addToStatement($stmt);
         $stmt->execute();
 
-        $cats = $stmt->getResult()->getIterator(ForumCategoryInfo::fromResult(...));
+        $cats = $stmt->getResultIterator(ForumCategoryInfo::fromResult(...));
 
         if($asTree)
             $cats = self::convertCategoryListToTree($cats);
@@ -274,7 +274,7 @@ class ForumCategoriesData {
         $stmt->nextParameter($categoryInfo);
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(ForumCategoryInfo::fromResult(...));
+        return $stmt->getResultIterator(ForumCategoryInfo::fromResult(...));
     }
 
     /** @return \Iterator<int, ForumCategoryInfo>|object{info: ForumCategoryInfo, colour: Colour, children: mixed[], childIds: string[]}[] */
@@ -310,7 +310,7 @@ class ForumCategoriesData {
             $stmt->nextParameter($parentInfo);
         $stmt->execute();
 
-        $cats = $stmt->getResult()->getIterator(ForumCategoryInfo::fromResult(...));
+        $cats = $stmt->getResultIterator(ForumCategoryInfo::fromResult(...));
 
         if($asTree)
             $cats = self::convertCategoryListToTree($cats, $parentInfo);
diff --git a/src/Forum/ForumCategoriesRoutes.php b/src/Forum/ForumCategoriesRoutes.php
index daa25996..d83c80db 100644
--- a/src/Forum/ForumCategoriesRoutes.php
+++ b/src/Forum/ForumCategoriesRoutes.php
@@ -166,7 +166,7 @@ class ForumCategoriesRoutes implements RouteHandler, UrlSource {
         return Template::renderRaw('forum.index', [
             'forum_categories' => $cats,
             'forum_empty' => empty($cats),
-            'forum_show_mark_as_read' => $this->authInfo->isLoggedIn,
+            'forum_show_mark_as_read' => $this->authInfo->loggedIn,
         ]);
     }
 
@@ -321,7 +321,7 @@ class ForumCategoriesRoutes implements RouteHandler, UrlSource {
             'forum_children' => $children,
             'forum_topics' => $topics,
             'forum_pagination' => $pagination,
-            'forum_show_mark_as_read' => $this->authInfo->isLoggedIn,
+            'forum_show_mark_as_read' => $this->authInfo->loggedIn,
             'forum_perms' => $perms->checkMany([
                 'can_create_topic' => Perm::F_TOPIC_CREATE,
             ]),
@@ -331,7 +331,7 @@ class ForumCategoriesRoutes implements RouteHandler, UrlSource {
     #[HttpPost('/forum/mark-as-read')]
     #[UrlFormat('forum-mark-as-read', '/forum/mark-as-read', ['cat' => '<category>', 'rec' => '<recursive>'])]
     public function postMarkAsRead(HttpResponseBuilder $response, HttpRequest $request): mixed {
-        if(!$this->authInfo->isLoggedIn)
+        if(!$this->authInfo->loggedIn)
             return 401;
 
         if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
diff --git a/src/Forum/ForumPostsData.php b/src/Forum/ForumPostsData.php
index 32551d0e..af453f54 100644
--- a/src/Forum/ForumPostsData.php
+++ b/src/Forum/ForumPostsData.php
@@ -185,7 +185,7 @@ class ForumPostsData {
             $pagination->addToStatement($stmt);
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(ForumPostInfo::fromResult(...));
+        return $stmt->getResultIterator(ForumPostInfo::fromResult(...));
     }
 
     /** @param ForumCategoryInfo|string|null|array<ForumCategoryInfo|string|int> $categoryInfos */
diff --git a/src/Forum/ForumPostsRoutes.php b/src/Forum/ForumPostsRoutes.php
index e472b883..8a64eb6b 100644
--- a/src/Forum/ForumPostsRoutes.php
+++ b/src/Forum/ForumPostsRoutes.php
@@ -57,7 +57,7 @@ class ForumPostsRoutes implements RouteHandler, UrlSource {
     #[HttpDelete('/forum/posts/([0-9]+)')]
     #[UrlFormat('forum-post-delete', '/forum/posts/<post>')]
     public function deletePost(HttpResponseBuilder $response, HttpRequest $request, string $postId): mixed {
-        if(!$this->authInfo->isLoggedIn)
+        if(!$this->authInfo->loggedIn)
             return 401;
 
         if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
@@ -192,7 +192,7 @@ class ForumPostsRoutes implements RouteHandler, UrlSource {
     #[HttpPost('/forum/posts/([0-9]+)/nuke')]
     #[UrlFormat('forum-post-nuke', '/forum/posts/<post>/nuke')]
     public function postPostNuke(HttpResponseBuilder $response, HttpRequest $request, string $postId): mixed {
-        if(!$this->authInfo->isLoggedIn)
+        if(!$this->authInfo->loggedIn)
             return 401;
 
         if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
@@ -272,7 +272,7 @@ class ForumPostsRoutes implements RouteHandler, UrlSource {
     #[HttpPost('/forum/posts/([0-9]+)/restore')]
     #[UrlFormat('forum-post-restore', '/forum/posts/<post>/restore')]
     public function postPostRestore(HttpResponseBuilder $response, HttpRequest $request, string $postId): mixed {
-        if(!$this->authInfo->isLoggedIn)
+        if(!$this->authInfo->loggedIn)
             return 401;
 
         if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
diff --git a/src/Forum/ForumTopicRedirectsData.php b/src/Forum/ForumTopicRedirectsData.php
index a3fd6255..f9e28394 100644
--- a/src/Forum/ForumTopicRedirectsData.php
+++ b/src/Forum/ForumTopicRedirectsData.php
@@ -58,7 +58,7 @@ class ForumTopicRedirectsData {
             $pagination->addToStatement($stmt);
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(ForumTopicRedirectInfo::fromResult(...));
+        return $stmt->getResultIterator(ForumTopicRedirectInfo::fromResult(...));
     }
 
     public function hasTopicRedirect(ForumTopicInfo|string $topicInfo): bool {
diff --git a/src/Forum/ForumTopicsData.php b/src/Forum/ForumTopicsData.php
index 270fa2a6..9c7cfee1 100644
--- a/src/Forum/ForumTopicsData.php
+++ b/src/Forum/ForumTopicsData.php
@@ -192,7 +192,7 @@ class ForumTopicsData {
             $pagination->addToStatement($stmt);
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(ForumTopicInfo::fromResult(...));
+        return $stmt->getResultIterator(ForumTopicInfo::fromResult(...));
     }
 
     public function getTopic(
diff --git a/src/Forum/ForumTopicsRoutes.php b/src/Forum/ForumTopicsRoutes.php
index 9f597a5e..d24b6e18 100644
--- a/src/Forum/ForumTopicsRoutes.php
+++ b/src/Forum/ForumTopicsRoutes.php
@@ -149,7 +149,7 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
     #[HttpDelete('/forum/topics/([0-9]+)')]
     #[UrlFormat('forum-topic-delete', '/forum/topics/<topic>')]
     public function deleteTopic(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
-        if(!$this->authInfo->isLoggedIn)
+        if(!$this->authInfo->loggedIn)
             return 401;
 
         if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
@@ -269,7 +269,7 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
     #[HttpPost('/forum/topics/([0-9]+)/restore')]
     #[UrlFormat('forum-topic-restore', '/forum/topics/<topic>/restore')]
     public function postTopicRestore(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
-        if(!$this->authInfo->isLoggedIn)
+        if(!$this->authInfo->loggedIn)
             return 401;
 
         if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
@@ -349,7 +349,7 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
     #[HttpPost('/forum/topics/([0-9]+)/nuke')]
     #[UrlFormat('forum-topic-nuke', '/forum/topics/<topic>/nuke')]
     public function postTopicNuke(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
-        if(!$this->authInfo->isLoggedIn)
+        if(!$this->authInfo->loggedIn)
             return 401;
 
         if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
@@ -429,7 +429,7 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
     #[HttpPost('/forum/topics/([0-9]+)/bump')]
     #[UrlFormat('forum-topic-bump', '/forum/topics/<topic>/bump')]
     public function postTopicBump(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
-        if(!$this->authInfo->isLoggedIn)
+        if(!$this->authInfo->loggedIn)
             return 401;
 
         if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
@@ -509,7 +509,7 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
     #[HttpPost('/forum/topics/([0-9]+)/lock')]
     #[UrlFormat('forum-topic-lock', '/forum/topics/<topic>/lock')]
     public function postTopicLock(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
-        if(!$this->authInfo->isLoggedIn)
+        if(!$this->authInfo->loggedIn)
             return 401;
 
         if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
@@ -599,7 +599,7 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
     #[HttpPost('/forum/topics/([0-9]+)/unlock')]
     #[UrlFormat('forum-topic-unlock', '/forum/topics/<topic>/unlock')]
     public function postTopicUnlock(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
-        if(!$this->authInfo->isLoggedIn)
+        if(!$this->authInfo->loggedIn)
             return 401;
 
         if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
diff --git a/src/Home/HomeRoutes.php b/src/Home/HomeRoutes.php
index bf8536e0..e8cbc99c 100644
--- a/src/Home/HomeRoutes.php
+++ b/src/Home/HomeRoutes.php
@@ -199,7 +199,7 @@ class HomeRoutes implements RouteHandler, UrlSource {
     #[HttpGet('/')]
     #[UrlFormat('index', '/')]
     public function getIndex(HttpResponseBuilder $response, HttpRequest $request): string {
-        return $this->authInfo->isLoggedIn
+        return $this->authInfo->loggedIn
             ? $this->getHome()
             : $this->getLanding($response, $request);
     }
diff --git a/src/Messages/MessagesData.php b/src/Messages/MessagesData.php
index b308d766..e218b669 100644
--- a/src/Messages/MessagesData.php
+++ b/src/Messages/MessagesData.php
@@ -151,7 +151,7 @@ class MessagesData {
 
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(MessageInfo::fromResult(...));
+        return $stmt->getResultIterator(MessageInfo::fromResult(...));
     }
 
     public function getMessageInfo(
diff --git a/src/Messages/MessagesRoutes.php b/src/Messages/MessagesRoutes.php
index 33c11d9a..b2b794f1 100644
--- a/src/Messages/MessagesRoutes.php
+++ b/src/Messages/MessagesRoutes.php
@@ -41,7 +41,7 @@ class MessagesRoutes implements RouteHandler, UrlSource {
     #[HttpMiddleware('/messages')]
     public function checkAccess(HttpResponseBuilder $response, HttpRequest $request) {
         // should probably be a permission or something too
-        if(!$this->authInfo->isLoggedIn)
+        if(!$this->authInfo->loggedIn)
             return 401;
 
         // do not allow access to PMs when impersonating in production mode
diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php
index 89e31794..20c0e08b 100644
--- a/src/MisuzuContext.php
+++ b/src/MisuzuContext.php
@@ -9,6 +9,7 @@ use Index\Http\HttpRequest;
 use Index\Templating\TplEnvironment;
 use Index\Urls\UrlRegistry;
 use Misuzu\Template;
+use Misuzu\Apps\AppsContext;
 use Misuzu\Auth\{AuthContext,AuthInfo};
 use Misuzu\AuditLog\AuditLogData;
 use Misuzu\Changelog\ChangelogData;
@@ -18,6 +19,7 @@ use Misuzu\Emoticons\EmotesData;
 use Misuzu\Forum\ForumContext;
 use Misuzu\Messages\MessagesContext;
 use Misuzu\News\NewsData;
+use Misuzu\OAuth2\OAuth2Context;
 use Misuzu\Perms\PermissionsData;
 use Misuzu\Profile\ProfileFieldsData;
 use Misuzu\Redirects\RedirectsContext;
@@ -45,9 +47,11 @@ class MisuzuContext {
     public private(set) NewsData $news;
     public private(set) CommentsData $comments;
 
+    public private(set) AppsContext $appsCtx;
     public private(set) AuthContext $authCtx;
     public private(set) ForumContext $forumCtx;
     public private(set) MessagesContext $messagesCtx;
+    public private(set) OAuth2Context $oauth2Ctx;
     public private(set) UsersContext $usersCtx;
     public private(set) RedirectsContext $redirectsCtx;
 
@@ -75,9 +79,11 @@ class MisuzuContext {
         $this->deps->register($this->authInfo = $this->deps->constructLazy(AuthInfo::class));
         $this->deps->register($this->siteInfo = $this->deps->constructLazy(SiteInfo::class, config: $config->scopeTo('site')));
 
+        $this->deps->register($this->appsCtx = $this->deps->constructLazy(AppsContext::class));
         $this->deps->register($this->authCtx = $this->deps->constructLazy(AuthContext::class, config: $config->scopeTo('auth')));
         $this->deps->register($this->forumCtx = $this->deps->constructLazy(ForumContext::class));
         $this->deps->register($this->messagesCtx = $this->deps->constructLazy(MessagesContext::class));
+        $this->deps->register($this->oauth2Ctx = $this->deps->constructLazy(OAuth2Context::class, config: $config->scopeTo('oauth2')));
         $this->deps->register($this->usersCtx = $this->deps->constructLazy(UsersContext::class));
         $this->deps->register($this->redirectsCtx = $this->deps->constructLazy(RedirectsContext::class, config: $config->scopeTo('redirects')));
 
@@ -105,7 +111,7 @@ class MisuzuContext {
 
     /** @param mixed[] $params */
     public function createAuditLog(string $action, array $params = [], UserInfo|string|null $userInfo = null): void {
-        if($userInfo === null && $this->authInfo->isLoggedIn)
+        if($userInfo === null && $this->authInfo->loggedIn)
             $userInfo = $this->authInfo->userInfo;
 
         $this->auditLog->createLog(
@@ -119,7 +125,7 @@ class MisuzuContext {
 
     private ?bool $hasManageAccess = null;
     public function hasManageAccess(): bool {
-        $this->hasManageAccess ??= $this->authInfo->isLoggedIn
+        $this->hasManageAccess ??= $this->authInfo->loggedIn
             && !$this->usersCtx->hasActiveBan($this->authInfo->userInfo)
             && $this->authInfo->getPerms('global')->check(Perm::G_IS_JANITOR);
         return $this->hasManageAccess;
@@ -167,6 +173,11 @@ class MisuzuContext {
         $routingCtx = new BackedRoutingContext;
         $this->deps->register($this->urls = $routingCtx->urls);
 
+        $rpcServer = new HttpRpcServer;
+        $routingCtx->register($rpcServer->createRouteHandler(
+            new HmacVerificationProvider(fn() => $this->config->getString('aleister.secret'))
+        ));
+
         if(in_array('main', $purposes)) {
             $scopedInfo = $hostInfo->scopeTo('main');
             $scopedCtx = $routingCtx->scopeTo(
@@ -197,10 +208,6 @@ class MisuzuContext {
             ));
             $scopedCtx->register($this->deps->constructLazy(LegacyRoutes::class));
 
-            $rpcServer = new HttpRpcServer;
-            $scopedCtx->register($rpcServer->createRouteHandler(
-                new HmacVerificationProvider(fn() => $this->config->getString('aleister.secret'))
-            ));
             $rpcServer->register($this->deps->constructLazy(
                 Auth\AuthRpcHandler::class,
                 impersonateConfig: $this->config->scopeTo('impersonate')
@@ -220,6 +227,18 @@ class MisuzuContext {
             ));
         }
 
+        if(in_array('id', $purposes)) {
+            $scopedInfo = $hostInfo->scopeTo('id');
+            $scopedCtx = $routingCtx->scopeTo(
+                $scopedInfo->getString('name'),
+                $scopedInfo->getString('path')
+            );
+
+            $scopedCtx->register($this->deps->constructLazy(OAuth2\OAuth2ApiRoutes::class));
+            $scopedCtx->register($this->deps->constructLazy(OAuth2\OAuth2WebRoutes::class));
+            $rpcServer->register($this->deps->constructLazy(OAuth2\OAuth2RpcHandler::class));
+        }
+
         if(in_array('redirect', $purposes)) {
             $scopedInfo = $hostInfo->scopeTo('redirect');
             $scopedCtx = $routingCtx->scopeTo(
diff --git a/src/News/NewsData.php b/src/News/NewsData.php
index e78d4a8f..65e27df7 100644
--- a/src/News/NewsData.php
+++ b/src/News/NewsData.php
@@ -53,7 +53,7 @@ class NewsData {
 
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(NewsCategoryInfo::fromResult(...));
+        return $stmt->getResultIterator(NewsCategoryInfo::fromResult(...));
     }
 
     public function getCategory(
@@ -255,7 +255,7 @@ class NewsData {
 
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(NewsPostInfo::fromResult(...));
+        return $stmt->getResultIterator(NewsPostInfo::fromResult(...));
     }
 
     public function getPost(string $postId): NewsPostInfo {
diff --git a/src/OAuth2/OAuth2AccessInfo.php b/src/OAuth2/OAuth2AccessInfo.php
new file mode 100644
index 00000000..83a1c661
--- /dev/null
+++ b/src/OAuth2/OAuth2AccessInfo.php
@@ -0,0 +1,52 @@
+<?php
+namespace Misuzu\OAuth2;
+
+use Carbon\CarbonImmutable;
+use Index\Db\DbResult;
+
+class OAuth2AccessInfo {
+    public const int DEFAULT_LIFETIME = 3600;
+
+    public function __construct(
+        public private(set) string $id,
+        public private(set) string $appId,
+        public private(set) ?string $userId,
+        public private(set) string $token,
+        public private(set) string $scope,
+        public private(set) int $createdTime,
+        public private(set) int $expiresTime
+    ) {}
+
+    public static function fromResult(DbResult $result): OAuth2AccessInfo {
+        return new OAuth2AccessInfo(
+            id: $result->getString(0),
+            appId: $result->getString(1),
+            userId: $result->getStringOrNull(2),
+            token: $result->getString(3),
+            scope: $result->getString(4),
+            createdTime: $result->getInteger(5),
+            expiresTime: $result->getInteger(6),
+        );
+    }
+
+    /** @var string[] */
+    public array $scopes {
+        get => explode(' ', $this->scope);
+    }
+
+    public CarbonImmutable $createdAt {
+        get => CarbonImmutable::createFromTimestampUTC($this->createdTime);
+    }
+
+    public bool $expired {
+        get => time() > $this->expiresTime;
+    }
+
+    public CarbonImmutable $expiresAt {
+        get => CarbonImmutable::createFromTimestampUTC($this->expiresTime);
+    }
+
+    public int $remainingLifetime {
+        get => max(0, $this->expiresTime - time());
+    }
+}
diff --git a/src/OAuth2/OAuth2AccessInfoGetField.php b/src/OAuth2/OAuth2AccessInfoGetField.php
new file mode 100644
index 00000000..d9568046
--- /dev/null
+++ b/src/OAuth2/OAuth2AccessInfoGetField.php
@@ -0,0 +1,7 @@
+<?php
+namespace Misuzu\OAuth2;
+
+enum OAuth2AccessInfoGetField {
+    case Id;
+    case Token;
+}
diff --git a/src/OAuth2/OAuth2ApiRoutes.php b/src/OAuth2/OAuth2ApiRoutes.php
new file mode 100644
index 00000000..15a42654
--- /dev/null
+++ b/src/OAuth2/OAuth2ApiRoutes.php
@@ -0,0 +1,215 @@
+<?php
+namespace Misuzu\OAuth2;
+
+use RuntimeException;
+use Index\Http\{FormHttpContent,HttpResponseBuilder,HttpRequest};
+use Index\Http\Routing\{HttpGet,HttpOptions,HttpPost,RouteHandler,RouteHandlerCommon};
+
+final class OAuth2ApiRoutes implements RouteHandler {
+    use RouteHandlerCommon;
+
+    public function __construct(
+        private OAuth2Context $oauth2Ctx
+    ) {}
+
+    /**
+     * @param array<string, scalar> $result
+     * @return array<string, scalar>
+     */
+    private static function filter(
+        HttpResponseBuilder $response,
+        HttpRequest $request,
+        array $result,
+        bool $authzHeader = false
+    ): array {
+        if(array_key_exists('error', $result)) {
+            if($authzHeader) {
+                $wwwAuth = sprintf('Basic realm="%s"', (string)$request->getHeaderLine('Host'));
+                foreach($result as $name => $value)
+                    $wwwAuth .= sprintf(', %s="%s"', $name, rawurlencode($value));
+
+                $response->statusCode = 401;
+                $response->setHeader('WWW-Authenticate', $wwwAuth);
+            } else
+                $response->statusCode = 400;
+        }
+
+        return $result;
+    }
+
+    /**
+     * @return array{
+     *  device_code: string,
+     *  user_code: string,
+     *  verification_uri: string,
+     *  verification_uri_complete: string,
+     *  expires_in?: int,
+     *  interval?: int
+     * }|array{ error: string, error_description: string }
+     */
+    #[HttpPost('/oauth2/request-authorise')]
+    public function postRequestAuthorise(HttpResponseBuilder $response, HttpRequest $request): array {
+        $response->setHeader('Cache-Control', 'no-store');
+
+        if(!($request->content instanceof FormHttpContent))
+            return self::filter($response, $request, [
+                'error' => 'invalid_request',
+                'error_description' => 'Your request must use content type application/x-www-form-urlencoded.',
+            ]);
+
+        $authzHeader = explode(' ', (string)$request->getHeaderLine('Authorization'));
+        if(strcasecmp($authzHeader[0], 'Basic') === 0) {
+            $authzHeader = explode(':', base64_decode($authzHeader[1] ?? ''));
+            $clientId = $authzHeader[0];
+            $clientSecret = $authzHeader[1] ?? '';
+        } elseif($authzHeader[0] !== '') {
+            return self::filter($response, $request, [
+                'error' => 'invalid_client',
+                'error_description' => 'You must use the Basic method for Authorization parameters.',
+            ], authzHeader: true);
+        } else {
+            $clientId = (string)$request->content->getParam('client_id');
+            $clientSecret = '';
+        }
+
+        try {
+            $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(clientId: $clientId, deleted: false);
+        } catch(RuntimeException $ex) {
+            return self::filter($response, $request, [
+                'error' => 'invalid_client',
+                'error_description' => 'No application has been registered with this client ID.',
+            ], authzHeader: $authzHeader[0] !== '');
+        }
+
+        if($clientSecret !== '') {
+            // TODO: rate limiting
+            if(!$appInfo->verifyClientSecret($clientSecret))
+                return self::filter($response, $request, [
+                    'error' => 'invalid_client',
+                    'error_description' => 'Provided client secret is not correct for this application.',
+                ], authzHeader: true);
+        }
+
+        return self::filter($response, $request, $this->oauth2Ctx->createDeviceAuthorisationRequest(
+            $appInfo,
+            $request->content->hasParam('scope') ? (string)$request->content->getParam('scope') : null
+        ));
+    }
+
+    /**
+     * @return array{
+     *  access_token: string,
+     *  token_type: 'Bearer',
+     *  expires_in?: int,
+     *  scope?: string,
+     *  refresh_token?: string,
+     * }|array{ error: string, error_description: string }
+     */
+    #[HttpOptions('/oauth2/token')]
+    #[HttpPost('/oauth2/token')]
+    public function postToken(HttpResponseBuilder $response, HttpRequest $request): array|int {
+        $response->setHeader('Cache-Control', 'no-store');
+
+        $originHeaders = ['Origin', 'X-Origin', 'Referer'];
+        $origins = [];
+        foreach($originHeaders as $originHeader) {
+            $originHeader = $request->getHeaderFirstLine($originHeader);
+            if($originHeader !== '' && !in_array($originHeader, $origins))
+                $origins[] = $originHeader;
+        }
+
+        if(!empty($origins)) {
+            // TODO: check if none of the provided origins is on a blocklist or something
+            // different origins being specified for each header should probably also be considered suspect...
+
+            $response->setHeader('Access-Control-Allow-Origin', $origins[0]);
+            $response->setHeader('Access-Control-Allow-Methods', 'OPTIONS, POST');
+            $response->setHeader('Access-Control-Allow-Headers', 'Authorization');
+            $response->setHeader('Access-Control-Expose-Headers', 'Vary');
+            foreach($originHeaders as $originHeader)
+                $response->setHeader('Vary', $originHeader);
+        }
+
+        if($request->method === 'OPTIONS')
+            return 204;
+
+        if(!($request->content instanceof FormHttpContent))
+            return self::filter($response, $request, [
+                'error' => 'invalid_request',
+                'error_description' => 'Your request must use content type application/x-www-form-urlencoded.',
+            ]);
+
+        // authz header should be the preferred method
+        $authzHeader = explode(' ', (string)$request->getHeaderLine('Authorization'));
+        if(strcasecmp($authzHeader[0], 'Basic') === 0) {
+            $authzHeader = explode(':', base64_decode($authzHeader[1] ?? ''));
+            $clientId = $authzHeader[0];
+            $clientSecret = $authzHeader[1] ?? '';
+        } elseif($authzHeader[0] !== '') {
+            return self::filter($response, $request, [
+                'error' => 'invalid_client',
+                'error_description' => 'You must either use the Basic method for Authorization or use the client_id and client_secret parameters.',
+            ], authzHeader: true);
+        } else {
+            $clientId = (string)$request->content->getParam('client_id');
+            $clientSecret = (string)$request->content->getParam('client_secret');
+        }
+
+        try {
+            $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(clientId: $clientId, deleted: false);
+        } catch(RuntimeException $ex) {
+            return self::filter($response, $request, [
+                'error' => 'invalid_client',
+                'error_description' => 'No application has been registered with this client ID.',
+            ], authzHeader: $authzHeader[0] !== '');
+        }
+
+        $isAuthed = false;
+        if($clientSecret !== '') {
+            // TODO: rate limiting
+            $isAuthed = $appInfo->verifyClientSecret($clientSecret);
+            if(!$isAuthed)
+                return self::filter($response, $request, [
+                    'error' => 'invalid_client',
+                    'error_description' => 'Provided client secret is not correct for this application.',
+                ], authzHeader: $authzHeader[0] !== '');
+        }
+
+        $type = (string)$request->content->getParam('grant_type');
+
+        if($type === 'authorization_code')
+            return self::filter($response, $request, $this->oauth2Ctx->redeemAuthorisationCode(
+                $appInfo,
+                $isAuthed,
+                (string)$request->content->getParam('code'),
+                (string)$request->content->getParam('code_verifier')
+            ));
+
+        if($type === 'refresh_token')
+            return self::filter($response, $request, $this->oauth2Ctx->redeemRefreshToken(
+                $appInfo,
+                $isAuthed,
+                (string)$request->content->getParam('refresh_token'),
+                $request->content->hasParam('scope') ? (string)$request->content->getParam('scope') : null
+            ));
+
+        if($type === 'client_credentials')
+            return self::filter($response, $request, $this->oauth2Ctx->redeemClientCredentials(
+                $appInfo,
+                $isAuthed,
+                $request->content->hasParam('scope') ? (string)$request->content->getParam('scope') : null
+            ));
+
+        if($type === 'urn:ietf:params:oauth:grant-type:device_code' || $type === 'device_code')
+            return self::filter($response, $request, $this->oauth2Ctx->redeemDeviceCode(
+                $appInfo,
+                $isAuthed,
+                (string)$request->content->getParam('device_code')
+            ));
+
+        return self::filter($response, $request, [
+            'error' => 'unsupported_grant_type',
+            'error_description' => 'Requested grant type is not supported by this server.',
+        ]);
+    }
+}
diff --git a/src/OAuth2/OAuth2AuthorisationData.php b/src/OAuth2/OAuth2AuthorisationData.php
new file mode 100644
index 00000000..eac0fb74
--- /dev/null
+++ b/src/OAuth2/OAuth2AuthorisationData.php
@@ -0,0 +1,113 @@
+<?php
+namespace Misuzu\OAuth2;
+
+use InvalidArgumentException;
+use RuntimeException;
+use Index\XString;
+use Index\Db\{DbConnection,DbStatementCache};
+use Misuzu\Apps\{AppInfo,AppUriInfo};
+use Misuzu\Users\UserInfo;
+
+class OAuth2AuthorisationData {
+    private DbStatementCache $cache;
+
+    public function __construct(
+        private DbConnection $dbConn
+    ) {
+        $this->cache = new DbStatementCache($dbConn);
+    }
+
+    public function getAuthorisationInfo(
+        ?string $authsId = null,
+        AppInfo|string|null $appInfo = null,
+        ?string $code = null
+    ): OAuth2AuthorisationInfo {
+        $selectors = [];
+        $values = [];
+        if($authsId !== null) {
+            $selectors[] = 'auth_id = ?';
+            $values[] = $authsId;
+        }
+        if($appInfo !== null) {
+            $selectors[] = 'app_id = ?';
+            $values[] = $appInfo instanceof AppInfo ? $appInfo->id : $appInfo;
+        }
+        if($code !== null) {
+            $selectors[] = 'auth_code = ?';
+            $values[] = $code;
+        }
+
+        if(empty($selectors))
+            throw new RuntimeException('Insufficient data to do authorisation request lookup.');
+
+        $stmt = $this->cache->get(sprintf(
+            <<<SQL
+                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 msz_oauth2_authorise WHERE %s
+            SQL
+        , implode(' AND ', $selectors)));
+        foreach($values as $value)
+            $stmt->nextParameter($value);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        if(!$result->next())
+            throw new RuntimeException('Authorise request not found.');
+
+        return OAuth2AuthorisationInfo::fromResult($result);
+    }
+
+    public function createAuthorisation(
+        AppInfo|string $appInfo,
+        UserInfo|string $userInfo,
+        AppUriInfo|string $appUriInfo,
+        string $challengeCode,
+        string $challengeMethod,
+        string $scope,
+        ?string $code = null
+    ): OAuth2AuthorisationInfo {
+        $code ??= XString::random(60);
+
+        $stmt = $this->cache->get(<<<SQL
+            INSERT INTO msz_oauth2_authorise (
+                app_id, user_id, uri_id, auth_challenge_code, auth_challenge_method, auth_scope, auth_code
+            ) VALUES (?, ?, ?, ?, ?, ?, ?)
+        SQL);
+        $stmt->nextParameter($appInfo instanceof AppInfo ? $appInfo->id : $appInfo);
+        $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
+        $stmt->nextParameter($appUriInfo instanceof AppUriInfo ? $appUriInfo->id : $appUriInfo);
+        $stmt->nextParameter($challengeCode);
+        $stmt->nextParameter($challengeMethod);
+        $stmt->nextParameter($scope);
+        $stmt->nextParameter($code);
+        $stmt->execute();
+
+        return $this->getAuthorisationInfo(authsId: (string)$stmt->lastInsertId);
+    }
+
+    public function deleteAuthorisation(
+        OAuth2AuthorisationInfo|string|null $authsInfo = null
+    ): void {
+        $selectors = [];
+        $values = [];
+        if($authsInfo !== null) {
+            $selectors[] = 'auth_id = ?';
+            $values[] = $authsInfo instanceof OAuth2AuthorisationInfo ? $authsInfo->id : $authsInfo;
+        }
+
+        $query = 'DELETE FROM msz_oauth2_authorise';
+        if(!empty($selectors))
+            $query .= sprintf(' WHERE %s', implode(' AND ', $selectors));
+
+        $stmt = $this->cache->get($query);
+        foreach($values as $value)
+            $stmt->nextParameter($value);
+        $stmt->execute();
+    }
+
+    public function pruneExpiredAuthorisations(): int {
+        return (int)$this->dbConn->execute('DELETE FROM msz_oauth2_authorise WHERE auth_expires <= NOW() - INTERVAL 1 HOUR');
+    }
+}
diff --git a/src/OAuth2/OAuth2AuthorisationInfo.php b/src/OAuth2/OAuth2AuthorisationInfo.php
new file mode 100644
index 00000000..4ebcd203
--- /dev/null
+++ b/src/OAuth2/OAuth2AuthorisationInfo.php
@@ -0,0 +1,70 @@
+<?php
+namespace Misuzu\OAuth2;
+
+use Carbon\CarbonImmutable;
+use Index\UriBase64;
+use Index\Db\DbResult;
+
+class OAuth2AuthorisationInfo {
+    public function __construct(
+        public private(set) string $id,
+        public private(set) string $appId,
+        public private(set) string $userId,
+        public private(set) string $uriId,
+        public private(set) string $challengeCode,
+        public private(set) string $challengeMethod,
+        public private(set) string $scope,
+        public private(set) string $code,
+        public private(set) int $createdTime,
+        public private(set) int $expiresTime
+    ) {}
+
+    public static function fromResult(DbResult $result): OAuth2AuthorisationInfo {
+        return new OAuth2AuthorisationInfo(
+            id: $result->getString(0),
+            appId: $result->getString(1),
+            userId: $result->getString(2),
+            uriId: $result->getString(3),
+            challengeCode: $result->getString(4),
+            challengeMethod: $result->getString(5),
+            scope: $result->getString(6),
+            code: $result->getString(7),
+            createdTime: $result->getInteger(8),
+            expiresTime: $result->getInteger(9),
+        );
+    }
+
+    public function verifyCodeChallenge(string $codeVerifier): bool {
+        if($this->challengeMethod === 'plain')
+            return hash_equals($this->challengeCode, $codeVerifier);
+
+        if($this->challengeMethod === 'S256') {
+            $knownHash = UriBase64::decode($this->challengeCode);
+            $userHash = hash('sha256', $codeVerifier, true);
+            return hash_equals($knownHash, $userHash);
+        }
+
+        return false;
+    }
+
+    /** @var string[] */
+    public array $scopes {
+        get => explode(' ', $this->scope);
+    }
+
+    public CarbonImmutable $createdAt {
+        get => CarbonImmutable::createFromTimestampUTC($this->createdTime);
+    }
+
+    public bool $expired {
+        get => time() > $this->expiresTime;
+    }
+
+    public CarbonImmutable $expiresAt {
+        get => CarbonImmutable::createFromTimestampUTC($this->expiresTime);
+    }
+
+    public int $remainingLifetime {
+        get => max(0, $this->expiresTime - time());
+    }
+}
diff --git a/src/OAuth2/OAuth2Context.php b/src/OAuth2/OAuth2Context.php
new file mode 100644
index 00000000..84ed7e96
--- /dev/null
+++ b/src/OAuth2/OAuth2Context.php
@@ -0,0 +1,398 @@
+<?php
+namespace Misuzu\OAuth2;
+
+use RuntimeException;
+use Index\Config\Config;
+use Index\Db\DbConnection;
+use Misuzu\Apps\{AppsContext,AppInfo};
+use Misuzu\Users\UserInfo;
+
+class OAuth2Context {
+    public private(set) OAuth2AuthorisationData $authorisations;
+    public private(set) OAuth2TokensData $tokens;
+    public private(set) OAuth2DevicesData $devices;
+
+    public function __construct(
+        private Config $config,
+        DbConnection $dbConn,
+        public private(set) AppsContext $appsCtx
+    ) {
+        $this->authorisations = new OAuth2AuthorisationData($dbConn);
+        $this->tokens = new OAuth2TokensData($dbConn);
+        $this->devices = new OAuth2DevicesData($dbConn);
+    }
+
+    /**
+     * @param ?callable(string $line, scalar ...$args): void $logAction
+     */
+    public function pruneExpired($logAction = null): void {
+        $logAction ??= function() {};
+
+        $logAction('Pruning expired refresh tokens...');
+        $pruned = $this->tokens->pruneExpiredRefresh();
+        $logAction('  Removed %d!', $pruned);
+
+        $logAction('Pruning expired access tokens...');
+        $pruned = $this->tokens->pruneExpiredAccess();
+        $logAction('  Removed %d!', $pruned);
+
+        $logAction('Pruning expired device authorisation requests...');
+        $pruned = $this->devices->pruneExpiredDevices();
+        $logAction('  Removed %d!', $pruned);
+
+        $logAction('Pruning expired authorisation codes...');
+        $pruned = $this->authorisations->pruneExpiredAuthorisations();
+        $logAction('  Removed %d!', $pruned);
+    }
+
+    public function createAccess(
+        AppInfo $appInfo,
+        string $scope = '',
+        UserInfo|string|null $userInfo = null
+    ): OAuth2AccessInfo {
+        return $this->tokens->createAccess(
+            $appInfo,
+            userInfo: $userInfo,
+            scope: $scope,
+            lifetime: $appInfo->accessTokenLifetime,
+        );
+    }
+
+    public function createRefresh(
+        AppInfo $appInfo,
+        OAuth2AccessInfo $accessInfo
+    ): OAuth2RefreshInfo {
+        return $this->tokens->createRefresh(
+            $appInfo,
+            $accessInfo,
+            userInfo: $accessInfo->userId,
+            scope: $accessInfo->scope,
+            lifetime: $appInfo->refreshTokenLifetime,
+        );
+    }
+
+    /** @return string|array{ error: string, error_description: string } */
+    public function checkAndBuildScopeString(
+        AppInfo $appInfo,
+        ?string $scope = null,
+        bool $skipFail = false
+    ): string|array {
+        if($scope === null)
+            return '';
+
+        $scopeInfos = $this->appsCtx->handleScopeString($appInfo, $scope, breakOnFail: !$skipFail);
+        $scope = [];
+
+        foreach($scopeInfos as $scopeName => $scopeInfo) {
+            if(is_string($scopeInfo)) {
+                if($skipFail)
+                    continue;
+
+                return [
+                    'error' => 'invalid_scope',
+                    'error_description' => sprintf('Requested scope "%s" is %s.', $scopeName, $scopeInfo),
+                ];
+            }
+
+            $scope[] = $scopeInfo->string;
+        }
+
+        return implode(' ', $scope);
+    }
+
+    /**
+     * @return array{
+     *  device_code: string,
+     *  user_code: string,
+     *  verification_uri: string,
+     *  verification_uri_complete: string,
+     *  expires_in?: int,
+     *  interval?: int
+     * }|array{ error: string, error_description: string }
+     */
+    public function createDeviceAuthorisationRequest(AppInfo $appInfo, ?string $scope = null) {
+        $scope = $this->checkAndBuildScopeString($appInfo, $scope);
+        if(is_array($scope))
+            return $scope;
+
+        $deviceInfo = $this->devices->createDevice($appInfo, $scope);
+
+        $userCode = $deviceInfo->getUserCodeDashed();
+        $result = [
+            'device_code' => $deviceInfo->code,
+            'user_code' => $userCode,
+            'verification_uri' => $this->config->getString('device.verification_uri'),
+            'verification_uri_complete' => sprintf($this->config->getString('device.verification_uri_complete'), $userCode),
+        ];
+
+        $expiresIn = $deviceInfo->remainingLifetime;
+        if($expiresIn < OAuth2DeviceInfo::DEFAULT_LIFETIME)
+            $result['expires_in'] = $expiresIn;
+
+        $interval = $deviceInfo->interval;
+        if($interval > OAuth2DeviceInfo::DEFAULT_POLL_INTERVAL)
+            $result['interval'] = $interval;
+
+        return $result;
+    }
+
+    /**
+     * @return array{
+     *  access_token: string,
+     *  token_type: 'Bearer',
+     *  expires_in?: int,
+     *  scope?: string,
+     *  refresh_token?: string,
+     * }
+     */
+    public function packBearerTokenResult(
+        OAuth2AccessInfo $accessInfo,
+        ?OAuth2RefreshInfo $refreshInfo = null,
+        ?string $scope = null
+    ): array {
+        $result = [
+            'access_token' => $accessInfo->token,
+            'token_type' => 'Bearer',
+        ];
+
+        $expiresIn = $accessInfo->remainingLifetime;
+        if($expiresIn < OAuth2AccessInfo::DEFAULT_LIFETIME)
+            $result['expires_in'] = $expiresIn;
+
+        if($scope !== null)
+            $result['scope'] = $scope;
+
+        if($refreshInfo !== null)
+            $result['refresh_token'] = $refreshInfo->token;
+
+        return $result;
+    }
+
+    /**
+     * @return array{
+     *  access_token: string,
+     *  token_type: 'Bearer',
+     *  expires_in?: int,
+     *  scope?: string,
+     *  refresh_token?: string,
+     * }|array{ error: string, error_description: string }
+     */
+    public function redeemAuthorisationCode(AppInfo $appInfo, bool $isAuthed, string $code, string $codeVerifier): array {
+        try {
+            $authsInfo = $this->authorisations->getAuthorisationInfo(
+                appInfo: $appInfo,
+                code: $code,
+            );
+        } catch(RuntimeException $ex) {
+            return [
+                'error' => 'invalid_grant',
+                'error_description' => 'No authorisation request with this code exists.',
+            ];
+        }
+
+        if($authsInfo->expired)
+            return [
+                'error' => 'invalid_grant',
+                'error_description' => 'Authorisation request has expired.',
+            ];
+        if(!$authsInfo->verifyCodeChallenge($codeVerifier))
+            return [
+                'error' => 'invalid_request',
+                'error_description' => 'Code challenge verification failed.',
+            ];
+
+        $this->authorisations->deleteAuthorisation($authsInfo);
+
+        $scope = $this->checkAndBuildScopeString($appInfo, $authsInfo->scope, true);
+
+        $accessInfo = $this->createAccess(
+            $appInfo,
+            $scope,
+            $authsInfo->userId,
+        );
+
+        if($authsInfo->scope === $scope)
+            $scope = null;
+
+        $refreshInfo = $appInfo->issueRefreshToken
+            ? $this->createRefresh($appInfo, $accessInfo)
+            : null;
+
+        return $this->packBearerTokenResult($accessInfo, $refreshInfo, $scope);
+    }
+
+    /**
+     * @return array{
+     *  access_token: string,
+     *  token_type: 'Bearer',
+     *  expires_in?: int,
+     *  scope?: string,
+     *  refresh_token?: string,
+     * }|array{ error: string, error_description: string }
+     */
+    public function redeemRefreshToken(AppInfo $appInfo, bool $isAuthed, string $refreshToken, ?string $scope = null): array {
+        try {
+            $refreshInfo = $this->tokens->getRefreshInfo($refreshToken, OAuth2RefreshInfoGetField::Token);
+        } catch(RuntimeException $ex) {
+            return [
+                'error' => 'invalid_grant',
+                'error_description' => 'No such refresh token exists.',
+            ];
+        }
+
+        if($refreshInfo->appId !== $appInfo->id)
+            return [
+                'error' => 'invalid_grant',
+                'error_description' => 'This refresh token is not associated with this application.',
+            ];
+        if($refreshInfo->expired)
+            return [
+                'error' => 'invalid_grant',
+                'error_description' => 'This refresh token has expired.',
+            ];
+
+        $this->tokens->deleteRefresh($refreshInfo);
+
+        if(!empty($refreshInfo->accessId))
+            $this->tokens->deleteAccess(accessInfo: $refreshInfo->accessId);
+
+        if($scope === null)
+            $scope = $refreshInfo->scope;
+        elseif(!empty(array_diff(explode(' ', $scope), $refreshInfo->scopes)))
+            return [
+                'error' => 'invalid_scope',
+                'error_description' => 'You cannot request a greater scope than during initial authorisation, please restart authorisation.',
+            ];
+
+        $scope = $this->checkAndBuildScopeString($appInfo, $scope, true);
+
+        $accessInfo = $this->createAccess(
+            $appInfo,
+            $scope,
+            $refreshInfo->userId,
+        );
+
+        if($refreshInfo->scope === $scope)
+            $scope = null;
+
+        $refreshInfo = null;
+        if($appInfo->issueRefreshToken)
+            $refreshInfo = $this->createRefresh($appInfo, $accessInfo);
+
+        return $this->packBearerTokenResult($accessInfo, $refreshInfo, $scope);
+    }
+
+    /**
+     * @return array{
+     *  access_token: string,
+     *  token_type: 'Bearer',
+     *  expires_in?: int,
+     *  scope?: string,
+     *  refresh_token?: string,
+     * }|array{ error: string, error_description: string }
+     */
+    public function redeemClientCredentials(AppInfo $appInfo, bool $isAuthed, ?string $scope = null): array {
+        if(!$appInfo->confidential)
+            return [
+                'error' => 'unauthorized_client',
+                'error_description' => 'This application is not allowed to use this grant type.',
+            ];
+        if(!$isAuthed)
+            return [
+                'error' => 'invalid_client',
+                'error_description' => 'Application must authenticate with client secret in order to use this grant type.',
+            ];
+
+        $requestedScope = $scope;
+        $scope = $this->checkAndBuildScopeString($appInfo, $requestedScope, true);
+        $accessInfo = $this->createAccess($appInfo, $scope);
+
+        if($requestedScope !== null && $scope === '')
+            return [
+                'error' => 'invalid_scope',
+                'error_description' => 'Requested scope is not valid.',
+            ];
+
+        if($requestedScope === null || $scope === $requestedScope)
+            $scope = null;
+
+        return $this->packBearerTokenResult($accessInfo, scope: $scope);
+    }
+
+    /**
+     * @return array{
+     *  access_token: string,
+     *  token_type: 'Bearer',
+     *  expires_in?: int,
+     *  scope?: string,
+     *  refresh_token?: string,
+     * }|array{ error: string, error_description: string }
+     */
+    public function redeemDeviceCode(AppInfo $appInfo, bool $isAuthed, string $deviceCode): array {
+        try {
+            $deviceInfo = $this->devices->getDeviceInfo(
+                appInfo: $appInfo,
+                code: $deviceCode,
+            );
+        } catch(RuntimeException) {
+            return [
+                'error' => 'invalid_grant',
+                'error_description' => 'No such device code exists.',
+            ];
+        }
+
+        if($deviceInfo->expired)
+            return [
+                'error' => 'expired_token',
+                'error_description' => 'This device code has expired.',
+            ];
+
+        if($deviceInfo->speedy) {
+            $this->devices->incrementDevicePollInterval($deviceInfo);
+            return [
+                'error' => 'slow_down',
+                'error_description' => 'You are polling too fast, please increase your interval by 5 seconds.',
+            ];
+        }
+
+        if($deviceInfo->pending) {
+            $this->devices->bumpDevicePollTime($deviceInfo);
+            return [
+                'error' => 'authorization_pending',
+                'error_description' => 'User has not yet completed authorisation, check again in a bit.',
+            ];
+        }
+
+        $this->devices->deleteDevice($deviceInfo);
+
+        if(!$deviceInfo->approved)
+            return [
+                'error' => 'access_denied',
+                'error_description' => 'User has rejected authorisation attempt.',
+            ];
+
+        if(empty($deviceInfo->userId))
+            return [
+                'error' => 'invalid_request',
+                'error_description' => 'Device code was approved but has no associated user, please contact the system administrator because something is wrong.',
+            ];
+
+        $scope = $this->checkAndBuildScopeString($appInfo, $deviceInfo->scope, true);
+
+        $accessInfo = $this->createAccess(
+            $appInfo,
+            $scope,
+            $deviceInfo->userId,
+        );
+
+        // 'scope' only has to be in the response if it differs from what was requested
+        if($deviceInfo->scope === $scope)
+            $scope = null;
+
+        $refreshInfo = $appInfo->issueRefreshToken
+            ? $this->createRefresh($appInfo, $accessInfo)
+            : null;
+
+        return $this->packBearerTokenResult($accessInfo, $refreshInfo, $scope);
+    }
+}
diff --git a/src/OAuth2/OAuth2DeviceApproval.php b/src/OAuth2/OAuth2DeviceApproval.php
new file mode 100644
index 00000000..1aab7a33
--- /dev/null
+++ b/src/OAuth2/OAuth2DeviceApproval.php
@@ -0,0 +1,8 @@
+<?php
+namespace Misuzu\OAuth2;
+
+enum OAuth2DeviceApproval: string {
+    case Pending = 'pending';
+    case Approved = 'approved';
+    case Denied = 'denied';
+}
diff --git a/src/OAuth2/OAuth2DeviceInfo.php b/src/OAuth2/OAuth2DeviceInfo.php
new file mode 100644
index 00000000..a40860da
--- /dev/null
+++ b/src/OAuth2/OAuth2DeviceInfo.php
@@ -0,0 +1,87 @@
+<?php
+namespace Misuzu\OAuth2;
+
+use Carbon\CarbonImmutable;
+use Index\Db\DbResult;
+
+class OAuth2DeviceInfo {
+    public const int DEFAULT_LIFETIME = 600;
+    public const int DEFAULT_POLL_INTERVAL = 5;
+
+    public function __construct(
+        public private(set) string $id,
+        public private(set) string $appId,
+        public private(set) ?string $userId,
+        public private(set) string $code,
+        public private(set) string $userCode,
+        public private(set) int $interval,
+        public private(set) int $polledTime,
+        public private(set) string $scope,
+        public private(set) OAuth2DeviceApproval $approval,
+        public private(set) int $createdTime,
+        public private(set) int $expiresTime
+    ) {}
+
+    public static function fromResult(DbResult $result): OAuth2DeviceInfo {
+        return new OAuth2DeviceInfo(
+            id: $result->getString(0),
+            appId: $result->getString(1),
+            userId: $result->getStringOrNull(2),
+            code: $result->getString(3),
+            userCode: $result->getString(4),
+            interval: $result->getInteger(5),
+            polledTime: $result->getInteger(6),
+            scope: $result->getString(7),
+            approval: OAuth2DeviceApproval::tryFrom($result->getString(8)) ?? OAuth2DeviceApproval::Denied,
+            createdTime: $result->getInteger(9),
+            expiresTime: $result->getInteger(10),
+        );
+    }
+
+    public function getUserCodeDashed(int $interval = 3): string {
+        // how is this a stdlib function????? i mean thanks but ?????
+        // update: it was too good to be true, i need to trim anyway...
+        return trim(chunk_split($this->userCode, $interval, '-'), '-');
+    }
+
+    public CarbonImmutable $polledAt {
+        get => CarbonImmutable::createFromTimestampUTC($this->polledTime);
+    }
+
+    public bool $speedy {
+        get => ($this->polledTime + $this->interval) > time();
+    }
+
+    /** @var string[] */
+    public array $scopes {
+        get => explode(' ', $this->scope);
+    }
+
+    public bool $pending {
+        get => $this->approval === OAuth2DeviceApproval::Pending;
+    }
+
+    public bool $approved {
+        get => $this->approval === OAuth2DeviceApproval::Approved;
+    }
+
+    public bool $denied {
+        get => $this->approval === OAuth2DeviceApproval::Denied;
+    }
+
+    public CarbonImmutable $createdAt {
+        get => CarbonImmutable::createFromTimestampUTC($this->createdTime);
+    }
+
+    public bool $expired {
+        get => time() > $this->expiresTime;
+    }
+
+    public CarbonImmutable $expiresAt {
+        get => CarbonImmutable::createFromTimestampUTC($this->expiresTime);
+    }
+
+    public int $remainingLifetime {
+        get => max(0, $this->expiresTime - time());
+    }
+}
diff --git a/src/OAuth2/OAuth2DevicesData.php b/src/OAuth2/OAuth2DevicesData.php
new file mode 100644
index 00000000..05d049c2
--- /dev/null
+++ b/src/OAuth2/OAuth2DevicesData.php
@@ -0,0 +1,171 @@
+<?php
+namespace Misuzu\OAuth2;
+
+use InvalidArgumentException;
+use RuntimeException;
+use Index\XString;
+use Index\Db\{DbConnection,DbStatementCache};
+use Misuzu\Apps\AppInfo;
+use Misuzu\Users\UserInfo;
+
+class OAuth2DevicesData {
+    private const USER_CODE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
+
+    private DbStatementCache $cache;
+
+    public function __construct(
+        private DbConnection $dbConn
+    ) {
+        $this->cache = new DbStatementCache($dbConn);
+    }
+
+    public function getDeviceInfo(
+        ?string $deviceId = null,
+        AppInfo|string|null $appInfo = null,
+        UserInfo|string|null|false $userInfo = false,
+        ?string $code = null,
+        ?string $userCode = null
+    ): OAuth2DeviceInfo {
+        $selectors = [];
+        $values = [];
+        if($deviceId !== null) {
+            $selectors[] = 'dev_id = ?';
+            $values[] = $deviceId;
+        }
+        if($appInfo !== null) {
+            $selectors[] = 'app_id = ?';
+            $values[] = $appInfo instanceof AppInfo ? $appInfo->id : $appInfo;
+        }
+        if($userInfo !== false) {
+            if($userInfo === null) {
+                $selectors[] = 'user_id IS NULL';
+            } else {
+                $selectors[] = 'user_id = ?';
+                $values[] = $userInfo instanceof UserInfo ? $userInfo->id : $userInfo;
+            }
+        }
+        if($code !== null) {
+            $selectors[] = 'dev_code = ?';
+            $values[] = $code;
+        }
+        if($userCode !== null) {
+            $selectors[] = 'dev_user_code = ?';
+            $values[] = preg_replace('#[^A-Za-z2-7]#', '', strtr($userCode, ['0' => 'O', '1' => 'I', '8' => 'B']));
+        }
+
+        if(empty($selectors))
+            throw new RuntimeException('Insufficient data to do device authorisation request lookup.');
+
+        $args = 0;
+        $stmt = $this->cache->get(sprintf(<<<SQL
+            SELECT dev_id, app_id, user_id, dev_code, dev_user_code, dev_interval,
+                UNIX_TIMESTAMP(dev_polled), dev_scope, dev_approval,
+                UNIX_TIMESTAMP(dev_created), UNIX_TIMESTAMP(dev_expires)
+            FROM msz_oauth2_device
+            WHERE %s
+        SQL, implode(' AND ', $selectors)));
+        foreach($values as $value)
+            $stmt->nextParameter($value);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        if(!$result->next())
+            throw new RuntimeException('Device authorisation request not found.');
+
+        return OAuth2DeviceInfo::fromResult($result);
+    }
+
+    public function createDevice(
+        AppInfo|string $appInfo,
+        string $scope,
+        UserInfo|string|null $userInfo = null,
+        ?string $code = null,
+        ?string $userCode = null
+    ): OAuth2DeviceInfo {
+        $code ??= XString::random(60);
+        $userCode ??= XString::random(9, self::USER_CODE_CHARS);
+
+        $stmt = $this->cache->get(<<<SQL
+            INSERT INTO msz_oauth2_device (
+                app_id, user_id, dev_code, dev_user_code, dev_scope
+            ) VALUES (?, ?, ?, ?, ?)
+        SQL);
+        $stmt->nextParameter($appInfo instanceof AppInfo ? $appInfo->id : $appInfo);
+        $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
+        $stmt->nextParameter($code);
+        $stmt->nextParameter($userCode);
+        $stmt->nextParameter($scope);
+        $stmt->execute();
+
+        return $this->getDeviceInfo(deviceId: (string)$stmt->lastInsertId);
+    }
+
+    public function deleteDevice(
+        OAuth2DeviceInfo|string|null $deviceInfo = null
+    ): void {
+        $selectors = [];
+        $values = [];
+        if($deviceInfo !== null) {
+            $selectors[] = 'dev_id = ?';
+            $values[] = $deviceInfo instanceof OAuth2DeviceInfo ? $deviceInfo->id : $deviceInfo;
+        }
+
+        $query = 'DELETE FROM msz_oauth2_device';
+        if(!empty($selectors))
+            $query .= sprintf(' WHERE %s', implode(' AND ', $selectors));
+
+        $args = 0;
+        $stmt = $this->cache->get($query);
+        foreach($values as $value)
+            $stmt->nextParameter($value);
+        $stmt->execute();
+    }
+
+    public function setDeviceApproval(
+        OAuth2DeviceInfo|string $deviceInfo,
+        bool $approval,
+        UserInfo|string|null $userInfo = null
+    ): void {
+        if($deviceInfo instanceof OAuth2DeviceInfo) {
+            if(!$deviceInfo->pending)
+                return;
+
+            $deviceInfo = $deviceInfo->id;
+        }
+
+        $stmt = $this->cache->get(<<<SQL
+            UPDATE msz_oauth2_device
+            SET dev_approval = ?,
+                user_id = COALESCE(user_id, ?)
+            WHERE dev_id = ?
+            AND dev_approval = 'pending'
+            AND user_id IS NULL
+        SQL);
+        $stmt->nextParameter($approval ? 'approved' : 'denied');
+        $stmt->nextParameter($approval ? ($userInfo instanceof UserInfo ? $userInfo->id : $userInfo) : null);
+        $stmt->nextParameter($deviceInfo);
+        $stmt->execute();
+    }
+
+    public function bumpDevicePollTime(OAuth2DeviceInfo|string $deviceInfo): void {
+        $stmt = $this->cache->get('UPDATE msz_oauth2_device SET dev_polled = NOW() WHERE dev_id = ?');
+        $stmt->nextParameter($deviceInfo instanceof OAuth2DeviceInfo ? $deviceInfo->id : $deviceInfo);
+        $stmt->execute();
+    }
+
+    public function incrementDevicePollInterval(OAuth2DeviceInfo|string $deviceInfo, int $amount = 5): void {
+        $stmt = $this->cache->get(<<<SQL
+            UPDATE msz_oauth2_device
+            SET dev_interval = dev_interval + ?,
+                dev_polled = NOW()
+            WHERE dev_id = ?
+        SQL);
+        $stmt->nextParameter($amount);
+        $stmt->nextParameter($deviceInfo instanceof OAuth2DeviceInfo ? $deviceInfo->id : $deviceInfo);
+        $stmt->execute();
+    }
+
+    public function pruneExpiredDevices(): int {
+        return (int)$this->dbConn->execute('DELETE FROM msz_oauth2_device WHERE dev_expires <= NOW() - INTERVAL 1 HOUR');
+    }
+}
diff --git a/src/OAuth2/OAuth2RefreshInfo.php b/src/OAuth2/OAuth2RefreshInfo.php
new file mode 100644
index 00000000..2afe0bda
--- /dev/null
+++ b/src/OAuth2/OAuth2RefreshInfo.php
@@ -0,0 +1,52 @@
+<?php
+namespace Misuzu\OAuth2;
+
+use Carbon\CarbonImmutable;
+use Index\Db\DbResult;
+
+class OAuth2RefreshInfo {
+    public function __construct(
+        public private(set) string $id,
+        public private(set) string $appId,
+        public private(set) ?string $userId,
+        public private(set) ?string $accessId,
+        public private(set) string $token,
+        public private(set) string $scope,
+        public private(set) int $createdTime,
+        public private(set) int $expiresTime
+    ) {}
+
+    public static function fromResult(DbResult $result): OAuth2RefreshInfo {
+        return new OAuth2RefreshInfo(
+            id: $result->getString(0),
+            appId: $result->getString(1),
+            userId: $result->getStringOrNull(2),
+            accessId: $result->getStringOrNull(3),
+            token: $result->getString(4),
+            scope: $result->getString(5),
+            createdTime: $result->getInteger(6),
+            expiresTime: $result->getInteger(7),
+        );
+    }
+
+    /** @var string[] */
+    public array $scopes {
+        get => explode(' ', $this->scope);
+    }
+
+    public CarbonImmutable $createdAt {
+        get => CarbonImmutable::createFromTimestampUTC($this->createdTime);
+    }
+
+    public bool $expired {
+        get => time() > $this->expiresTime;
+    }
+
+    public CarbonImmutable $expiresAt {
+        get => CarbonImmutable::createFromTimestampUTC($this->expiresTime);
+    }
+
+    public int $remainingLifetime {
+        get => max(0, $this->expiresTime - time());
+    }
+}
diff --git a/src/OAuth2/OAuth2RefreshInfoGetField.php b/src/OAuth2/OAuth2RefreshInfoGetField.php
new file mode 100644
index 00000000..82b343a7
--- /dev/null
+++ b/src/OAuth2/OAuth2RefreshInfoGetField.php
@@ -0,0 +1,8 @@
+<?php
+namespace Misuzu\OAuth2;
+
+enum OAuth2RefreshInfoGetField {
+    case Id;
+    case Access;
+    case Token;
+}
diff --git a/src/OAuth2/OAuth2RpcHandler.php b/src/OAuth2/OAuth2RpcHandler.php
new file mode 100644
index 00000000..de32466c
--- /dev/null
+++ b/src/OAuth2/OAuth2RpcHandler.php
@@ -0,0 +1,196 @@
+<?php
+namespace Misuzu\OAuth2;
+
+use RuntimeException;
+use RPCii\Server\{RpcHandler,RpcHandlerCommon,RpcAction};
+
+final class OAuth2RpcHandler implements RpcHandler {
+    use RpcHandlerCommon;
+
+    public function __construct(
+        private OAuth2Context $oauth2Ctx
+    ) {}
+
+    /**
+     * @return array{
+     *  method: 'basic',
+     *  error?: 'app'|'secret',
+     *  type?: 'confapp'|'pubapp',
+     *  app?: string,
+     *  scope?: string[]
+     * }
+     */
+    #[RpcAction('hanyuu:oauth2:attemptAppAuth')]
+    public function procAttemptAppAuth(string $remoteAddr, string $clientId, string $clientSecret = ''): array {
+        try {
+            $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(clientId: $clientId, deleted: false);
+        } catch(RuntimeException $ex) {
+            return ['method' => 'basic', 'error' => 'app'];
+        }
+
+        $authed = false;
+        if($clientSecret !== '') {
+            // TODO: rate limiting
+
+            if(!$appInfo->verifyClientSecret($clientSecret))
+                return ['method' => 'basic', 'error' => 'secret'];
+
+            $authed = true;
+        }
+
+        return [
+            'method' => 'basic',
+            'type' => $authed ? 'confapp' : 'pubapp',
+            'app' => $appInfo->id,
+            'scope' => ['oauth2'],
+        ];
+    }
+
+    /**
+     * @return array{
+     *  method: 'bearer',
+     *  error?: 'token'|'expired',
+     *  type?: 'app'|'user',
+     *  app?: string,
+     *  user?: string,
+     *  scope?: string[],
+     *  expires?: int,
+     * }
+     */
+    #[RpcAction('hanyuu:oauth2:attemptBearerAuth')]
+    public function procAttemptBearerAuth(string $remoteAddr, string $token): array {
+        try {
+            $tokenInfo = $this->oauth2Ctx->tokens->getAccessInfo($token, OAuth2AccessInfoGetField::Token);
+        } catch(RuntimeException $ex) {
+            return ['method' => 'bearer', 'error' => 'token'];
+        }
+
+        if($tokenInfo->expired)
+            return ['method' => 'bearer', 'error' => 'expired'];
+
+        return [
+            'method' => 'bearer',
+            'type' => empty($tokenInfo->userId) ? 'app' : 'user',
+            'app' => $tokenInfo->appId,
+            'user' => $tokenInfo->userId ?? '0',
+            'scope' => $tokenInfo->scopes,
+            'expires' => $tokenInfo->expiresTime,
+        ];
+    }
+
+    /**
+     * @return array{
+     *  device_code: string,
+     *  user_code: string,
+     *  verification_uri: string,
+     *  verification_uri_complete: string,
+     *  expires_in?: int,
+     *  interval?: int
+     * }|array{ error: string, error_description: string }
+     */
+    #[RpcAction('hanyuu:oauth2:createAuthoriseRequest')]
+    public function procCreateAuthoriseRequest(string $appId, ?string $scope = null): array {
+        try {
+            $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(appId: $appId, deleted: false);
+        } catch(RuntimeException $ex) {
+            return [
+                'error' => 'invalid_client',
+                'error_description' => 'No application has been registered with this client ID.',
+            ];
+        }
+
+        return $this->oauth2Ctx->createDeviceAuthorisationRequest($appInfo, $scope);
+    }
+
+    /**
+     * @return array{
+     *  access_token: string,
+     *  token_type: 'Bearer',
+     *  expires_in?: int,
+     *  scope?: string,
+     *  refresh_token?: string,
+     * }|array{ error: string, error_description: string }
+     */
+    #[RpcAction('hanyuu:oauth2:createBearerToken:authorisationCode')]
+    public function procCreateBearerTokenAuthzCode(string $appId, bool $isAuthed, string $code, string $codeVerifier): array {
+        try {
+            $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(appId: $appId, deleted: false);
+        } catch(RuntimeException $ex) {
+            return [
+                'error' => 'invalid_client',
+                'error_description' => 'No application has been registered with this client ID.',
+            ];
+        }
+
+        return $this->oauth2Ctx->redeemAuthorisationCode($appInfo, $isAuthed, $code, $codeVerifier);
+    }
+
+    /**
+     * @return array{
+     *  access_token: string,
+     *  token_type: 'Bearer',
+     *  expires_in?: int,
+     *  scope?: string,
+     *  refresh_token?: string,
+     * }|array{ error: string, error_description: string }
+     */
+    #[RpcAction('hanyuu:oauth2:createBearerToken:refreshToken')]
+    public function procCreateBearerTokenRefreshToken(string $appId, bool $isAuthed, string $refreshToken, ?string $scope = null): array {
+        try {
+            $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(appId: $appId, deleted: false);
+        } catch(RuntimeException $ex) {
+            return [
+                'error' => 'invalid_client',
+                'error_description' => 'No application has been registered with this client ID.',
+            ];
+        }
+
+        return $this->oauth2Ctx->redeemRefreshToken($appInfo, $isAuthed, $refreshToken, $scope);
+    }
+
+    /**
+     * @return array{
+     *  access_token: string,
+     *  token_type: 'Bearer',
+     *  expires_in?: int,
+     *  scope?: string,
+     *  refresh_token?: string,
+     * }|array{ error: string, error_description: string }
+     */
+    #[RpcAction('hanyuu:oauth2:createBearerToken:clientCredentials')]
+    public function procCreateBearerTokenClientCreds(string $appId, bool $isAuthed, ?string $scope = null): array {
+        try {
+            $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(appId: $appId, deleted: false);
+        } catch(RuntimeException $ex) {
+            return [
+                'error' => 'invalid_client',
+                'error_description' => 'No application has been registered with this client ID.',
+            ];
+        }
+
+        return $this->oauth2Ctx->redeemClientCredentials($appInfo, $isAuthed, $scope);
+    }
+
+    /**
+     * @return array{
+     *  access_token: string,
+     *  token_type: 'Bearer',
+     *  expires_in?: int,
+     *  scope?: string,
+     *  refresh_token?: string,
+     * }|array{ error: string, error_description: string }
+     */
+    #[RpcAction('hanyuu:oauth2:createBearerToken:deviceCode')]
+    public function procCreateBearerTokenDeviceCode(string $appId, bool $isAuthed, string $deviceCode): array {
+        try {
+            $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(appId: $appId, deleted: false);
+        } catch(RuntimeException $ex) {
+            return [
+                'error' => 'invalid_client',
+                'error_description' => 'No application has been registered with this client ID.',
+            ];
+        }
+
+        return $this->oauth2Ctx->redeemDeviceCode($appInfo, $isAuthed, $deviceCode);
+    }
+}
diff --git a/src/OAuth2/OAuth2TokensData.php b/src/OAuth2/OAuth2TokensData.php
new file mode 100644
index 00000000..cd7f778c
--- /dev/null
+++ b/src/OAuth2/OAuth2TokensData.php
@@ -0,0 +1,207 @@
+<?php
+namespace Misuzu\OAuth2;
+
+use InvalidArgumentException;
+use RuntimeException;
+use Index\XString;
+use Index\Db\{DbConnection,DbStatementCache};
+use Misuzu\Apps\AppInfo;
+use Misuzu\Users\UserInfo;
+
+class OAuth2TokensData {
+    private DbStatementCache $cache;
+
+    public function __construct(
+        private DbConnection $dbConn
+    ) {
+        $this->cache = new DbStatementCache($dbConn);
+    }
+
+    public function getAccessInfo(string $value, OAuth2AccessInfoGetField $field): OAuth2AccessInfo {
+        if($field === OAuth2AccessInfoGetField::Id)
+            $field = 'acc_id';
+        elseif($field === OAuth2AccessInfoGetField::Token)
+            $field = 'acc_token';
+        else
+            throw new InvalidArgumentException('$field is not a valid mode');
+
+        $stmt = $this->cache->get(sprintf(<<<SQL
+            SELECT acc_id, app_id, user_id, acc_token, acc_scope,
+                UNIX_TIMESTAMP(acc_created), UNIX_TIMESTAMP(acc_expires)
+            FROM msz_oauth2_access
+            WHERE %s = ?
+        SQL, $field));
+        $stmt->nextParameter($value);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        if(!$result->next())
+            throw new RuntimeException('Access info not found.');
+
+        return OAuth2AccessInfo::fromResult($result);
+    }
+
+    public function createAccess(
+        AppInfo|string $appInfo,
+        UserInfo|string|null $userInfo = null,
+        ?string $token = null,
+        string $scope = '',
+        ?int $lifetime = null
+    ): OAuth2AccessInfo {
+        $token ??= XString::random(80);
+
+        $stmt = $this->cache->get(<<<SQL
+            INSERT INTO msz_oauth2_access (
+                app_id, user_id, acc_token, acc_scope,
+                acc_expires
+            ) VALUES (
+                ?, ?, ?, ?,
+                IF(?, NOW() + INTERVAL ? SECOND, DEFAULT(acc_expires))
+            )
+        SQL);
+        $stmt->nextParameter($appInfo instanceof AppInfo ? $appInfo->id : $appInfo);
+        $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
+        $stmt->nextParameter($token);
+        $stmt->nextParameter($scope);
+        $stmt->nextParameter($lifetime === null ? 0 : 1);
+        $stmt->nextParameter($lifetime);
+        $stmt->execute();
+
+        return $this->getAccessInfo((string)$stmt->lastInsertId, OAuth2AccessInfoGetField::Id);
+    }
+
+    public function deleteAccess(
+        OAuth2AccessInfo|string|null $accessInfo = null,
+        AppInfo|string|null $appInfo = null,
+        UserInfo|string|null $userInfo = null,
+    ): void {
+        $selectors = [];
+        $values = [];
+        if($accessInfo !== null) {
+            $selectors[] = 'acc_id = ?';
+            $values[] = $accessInfo instanceof OAuth2AccessInfo ? $accessInfo->id : $accessInfo;
+        }
+        if($appInfo !== null) {
+            $selectors[] = 'app_id = ?';
+            $values[] = $appInfo instanceof AppInfo ? $appInfo->id : $appInfo;
+        }
+        if($userInfo !== null) {
+            $selectors[] = 'user_id = ?';
+            $values[] = $userInfo instanceof UserInfo ? $userInfo->id : $userInfo;
+        }
+
+        $query = 'DELETE FROM msz_oauth2_access';
+        if(!empty($selectors))
+            $query .= sprintf(' WHERE %s', implode(' AND ', $selectors));
+
+        $stmt = $this->cache->get($query);
+        foreach($values as $value)
+            $stmt->nextParameter($value);
+        $stmt->execute();
+    }
+
+    public function pruneExpiredAccess(): int {
+        return (int)$this->dbConn->execute('DELETE FROM msz_oauth2_access WHERE acc_expires <= NOW() - INTERVAL 1 DAY');
+    }
+
+    public function getRefreshInfo(object|string $value, OAuth2RefreshInfoGetField $field): OAuth2RefreshInfo {
+        if($field === OAuth2RefreshInfoGetField::Id) {
+            $field = 'ref_id';
+        } elseif($field === OAuth2RefreshInfoGetField::Access) {
+            $field = 'acc_id';
+            if($value instanceof OAuth2AccessInfo)
+                $value = $value->id;
+        } elseif($field === OAuth2RefreshInfoGetField::Token) {
+            $field = 'ref_token';
+        } else
+            throw new InvalidArgumentException('$field is not a valid mode');
+
+        if(!is_string($value))
+            throw new InvalidArgumentException('$value must be a string');
+
+        $stmt = $this->cache->get(sprintf(<<<SQL
+            SELECT ref_id, app_id, user_id, acc_id, ref_token, ref_scope,
+                UNIX_TIMESTAMP(ref_created), UNIX_TIMESTAMP(ref_expires)
+            FROM msz_oauth2_refresh
+            WHERE %s = ?
+        SQL, $field));
+        $stmt->nextParameter($value);
+        $stmt->execute();
+
+        $result = $stmt->getResult();
+        if(!$result->next())
+            throw new RuntimeException('Refresh info not found.');
+
+        return OAuth2RefreshInfo::fromResult($result);
+    }
+
+    public function createRefresh(
+        AppInfo|string $appInfo,
+        OAuth2AccessInfo|string|null $accessInfo,
+        UserInfo|string|null $userInfo = null,
+        ?string $token = null,
+        string $scope = '',
+        ?int $lifetime = null
+    ): OAuth2RefreshInfo {
+        $token ??= XString::random(120);
+
+        $stmt = $this->cache->get(<<<SQL
+            INSERT INTO msz_oauth2_refresh (
+                app_id, user_id, acc_id, ref_token, ref_scope,
+                ref_expires
+            ) VALUES (
+                ?, ?, ?, ?, ?,
+                IF(?, NOW() + INTERVAL ? SECOND, DEFAULT(ref_expires))
+            )
+        SQL);
+        $stmt->nextParameter($appInfo instanceof AppInfo ? $appInfo->id : $appInfo);
+        $stmt->nextParameter($userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
+        $stmt->nextParameter($accessInfo instanceof OAuth2AccessInfo ? $accessInfo->id : $accessInfo);
+        $stmt->nextParameter($token);
+        $stmt->nextParameter($scope);
+        $stmt->nextParameter($lifetime === null ? 0 : 1);
+        $stmt->nextParameter($lifetime);
+        $stmt->execute();
+
+        return $this->getRefreshInfo((string)$stmt->lastInsertId, OAuth2RefreshInfoGetField::Id);
+    }
+
+    public function deleteRefresh(
+        OAuth2RefreshInfo|string|null $refreshInfo = null,
+        AppInfo|string|null $appInfo = null,
+        UserInfo|string|null $userInfo = null,
+        OAuth2AccessInfo|string|null $accessInfo = null
+    ): void {
+        $selectors = [];
+        $values = [];
+        if($refreshInfo !== null) {
+            $selectors[] = 'ref_id = ?';
+            $values[] = $refreshInfo instanceof OAuth2RefreshInfo ? $refreshInfo->id : $refreshInfo;
+        }
+        if($appInfo !== null) {
+            $selectors[] = 'app_id = ?';
+            $values[] = $appInfo instanceof AppInfo ? $appInfo->id : $appInfo;
+        }
+        if($userInfo !== null) {
+            $selectors[] = 'user_id = ?';
+            $values[] = $userInfo instanceof UserInfo ? $userInfo->id : $userInfo;
+        }
+        if($accessInfo !== null) {
+            $selectors[] = 'acc_id = ?';
+            $values[] = $accessInfo instanceof OAuth2AccessInfo ? $accessInfo->id : $accessInfo;
+        }
+
+        $query = 'DELETE FROM msz_oauth2_refresh';
+        if(!empty($selectors))
+            $query .= sprintf(' WHERE %s', implode(' AND ', $selectors));
+
+        $stmt = $this->cache->get($query);
+        foreach($values as $value)
+            $stmt->nextParameter($value);
+        $stmt->execute();
+    }
+
+    public function pruneExpiredRefresh(): int {
+        return (int)$this->dbConn->execute('DELETE FROM msz_oauth2_refresh WHERE ref_expires <= NOW()');
+    }
+}
diff --git a/src/OAuth2/OAuth2WebRoutes.php b/src/OAuth2/OAuth2WebRoutes.php
new file mode 100644
index 00000000..87710250
--- /dev/null
+++ b/src/OAuth2/OAuth2WebRoutes.php
@@ -0,0 +1,430 @@
+<?php
+namespace Misuzu\OAuth2;
+
+use InvalidArgumentException;
+use RuntimeException;
+use Index\Http\{FormHttpContent,HttpResponseBuilder,HttpRequest};
+use Index\Http\Routing\{HttpGet,HttpPost,RouteHandler,RouteHandlerCommon};
+use Index\Urls\UrlRegistry;
+use Misuzu\{CSRF,SiteInfo,Template};
+use Misuzu\Auth\AuthInfo;
+use Misuzu\Users\UsersContext;
+
+final class OAuth2WebRoutes implements RouteHandler {
+    use RouteHandlerCommon;
+
+    public function __construct(
+        private OAuth2Context $oauth2Ctx,
+        private UsersContext $usersCtx,
+        private UrlRegistry $urls,
+        private SiteInfo $siteInfo,
+        private AuthInfo $authInfo
+    ) {
+    }
+
+    #[HttpGet('/oauth2/authorise')]
+    #[HttpGet('/oauth2/authorize')]
+    public function getAuthorise(HttpResponseBuilder $response, HttpRequest $request): string {
+        if(!$this->authInfo->loggedIn)
+            return Template::renderRaw('oauth2.login', [
+                'login_url' => $this->urls->format('auth-login'),
+                'register_url' => $this->urls->format('auth-register'),
+            ]);
+
+        return Template::renderRaw('oauth2.authorise');
+    }
+
+    /**
+     * @return int|array{
+     *  error: 'auth'|'csrf'|'method'|'length'|'client'|'scope'|'format'|'required'|'authorise',
+     *  scope?: string,
+     *  reason?: string,
+     * }|array{
+     *  code: string,
+     *  redirect: string,
+     * }
+     */
+    #[HttpPost('/oauth2/authorise')]
+    public function postAuthorise(HttpResponseBuilder $response, HttpRequest $request): int|array {
+        if(!($request->content instanceof FormHttpContent))
+            return 400;
+
+        // TODO: RATE LIMITING
+
+        if(!$this->authInfo->loggedIn)
+            return ['error' => 'auth'];
+
+        if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
+            return ['error' => 'csrf'];
+
+        $response->setHeader('X-CSRF-Token', CSRF::token());
+
+        $codeChallengeMethod = 'plain';
+        if($request->content->hasParam('ccm')) {
+            $codeChallengeMethod = $request->content->getParam('ccm');
+            if(!in_array($codeChallengeMethod, ['plain', 'S256']))
+                return ['error' => 'method'];
+        }
+
+        $codeChallenge = $request->content->getParam('cc');
+        $codeChallengeLength = strlen($codeChallenge);
+        if($codeChallengeMethod === 'S256') {
+            if($codeChallengeLength !== 43)
+                return ['error' => 'length'];
+        } else {
+            if($codeChallengeLength < 43 || $codeChallengeLength > 128)
+                return ['error' => 'length'];
+        }
+
+        try {
+            $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(
+                clientId: (string)$request->content->getParam('client'),
+                deleted: false
+            );
+        } catch(RuntimeException $ex) {
+            return ['error' => 'client'];
+        }
+
+        if($request->content->hasParam('scope')) {
+            $scope = [];
+            $scopeInfos = $this->oauth2Ctx->appsCtx->handleScopeString($appInfo, (string)$request->content->getParam('scope'));
+
+            foreach($scopeInfos as $scopeName => $scopeInfo) {
+                if(is_string($scopeInfo))
+                    return ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo];
+
+                $scope[] = $scopeInfo->string;
+            }
+
+            $scope = implode(' ', $scope);
+        } else $scope = '';
+
+        if($request->content->hasParam('redirect')) {
+            $redirectUri = (string)$request->content->getParam('redirect');
+            $redirectUriId = $this->oauth2Ctx->appsCtx->apps->getAppUriId($appInfo, $redirectUri);
+            if($redirectUriId === null)
+                return ['error' => 'format'];
+        } else {
+            $uriInfos = $this->oauth2Ctx->appsCtx->apps->getAppUriInfos($appInfo);
+            if(count($uriInfos) !== 1)
+                return ['error' => 'required'];
+
+            $uriInfo = array_pop($uriInfos);
+            $redirectUri = $uriInfo->string;
+            $redirectUriId = $uriInfo->id;
+        }
+
+        try {
+            $authsInfo = $this->oauth2Ctx->authorisations->createAuthorisation(
+                $appInfo,
+                $this->authInfo->userInfo,
+                $redirectUriId,
+                $codeChallenge,
+                $codeChallengeMethod,
+                $scope
+            );
+        } catch(RuntimeException $ex) {
+            return ['error' => 'authorise', 'detail' => $ex->getMessage()];
+        }
+
+        return [
+            'code' => $authsInfo->code,
+            'redirect' => $redirectUri,
+        ];
+    }
+
+    /**
+     * @return array{
+     *  error: 'auth'|'csrf'|'client'|'scope'|'format'|'required',
+     *  scope?: string,
+     *  reason?: string,
+     * }|array{
+     *  app: array{
+     *   name: string,
+     *   summary: string,
+     *   trusted: bool,
+     *   links: array{ title: string, display: string, uri: string }[]
+     *  },
+     *  user: array{
+     *   name: string,
+     *   colour: string,
+     *   profile_uri: string,
+     *   avatar_uri: string,
+     *   guise?: array{
+     *    name: string,
+     *    colour: string,
+     *    profile_uri: string,
+     *    revert_uri: string,
+     *    avatar_uri: string,
+     *   },
+     *  },
+     *  scope: string[],
+     * }
+     */
+    #[HttpGet('/oauth2/resolve-authorise-app')]
+    public function getResolveAuthorise(HttpResponseBuilder $response, HttpRequest $request): array {
+        // TODO: RATE LIMITING
+
+        if(!$this->authInfo->loggedIn)
+            return ['error' => 'auth'];
+
+        if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
+            return ['error' => 'csrf'];
+
+        $response->setHeader('X-CSRF-Token', CSRF::token());
+
+        try {
+            $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(
+                clientId: (string)$request->getParam('client'),
+                deleted: false
+            );
+        } catch(RuntimeException $ex) {
+            return ['error' => 'client'];
+        }
+
+        if($request->hasParam('redirect')) {
+            $redirectUri = (string)$request->getParam('redirect');
+            if($this->oauth2Ctx->appsCtx->apps->getAppUriId($appInfo, $redirectUri) === null)
+                return ['error' => 'format'];
+        } else {
+            $uriInfos = $this->oauth2Ctx->appsCtx->apps->getAppUriInfos($appInfo);
+            if(count($uriInfos) !== 1)
+                return ['error' => 'required'];
+        }
+
+        $scope = [];
+        if($request->hasParam('scope')) {
+            $scopeInfos = $this->oauth2Ctx->appsCtx->handleScopeString($appInfo, (string)$request->getParam('scope'));
+
+            foreach($scopeInfos as $scopeName => $scopeInfo) {
+                if(is_string($scopeInfo))
+                    return ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo];
+
+                $scope[] = $scopeInfo->summary;
+            }
+        }
+
+        $result = [
+            'app' => [
+                'name' => $appInfo->name,
+                'summary' => $appInfo->summary,
+                'trusted' => $appInfo->trusted,
+                'links' => [
+                    ['title' => 'Website', 'display' => $appInfo->websiteForDisplay, 'uri' => $appInfo->website],
+                ],
+            ],
+            'user' => [
+                'name' => $this->authInfo->userInfo->name,
+                'colour' => (string)$this->usersCtx->getUserColour($this->authInfo->userInfo),
+                'profile_uri' => $this->siteInfo->url . $this->urls->format('user-profile', ['user' => $this->authInfo->userInfo->id]),
+                'avatar_uri' => $this->siteInfo->url . $this->urls->format('user-avatar', ['user' => $this->authInfo->userInfo->id, 'res' => 120]),
+            ],
+            'scope' => $scope,
+        ];
+
+        if($this->authInfo->isImpersonating)
+            $result['user']['guise'] = [
+                'name' => $this->authInfo->realUserInfo->name,
+                'colour' => (string)$this->usersCtx->getUserColour($this->authInfo->realUserInfo),
+                'profile_uri' => $this->siteInfo->url . $this->urls->format('user-profile', ['user' => $this->authInfo->realUserInfo->id]),
+                'revert_uri' => $this->siteInfo->url . $this->urls->format('auth-revert', ['csrf' => CSRF::token()]),
+                'avatar_uri' => $this->siteInfo->url . $this->urls->format('user-avatar', ['user' => $this->authInfo->realUserInfo->id, 'res' => 60]),
+            ];
+
+        return $result;
+    }
+
+    #[HttpGet('/oauth2/verify')]
+    public function getVerify(HttpResponseBuilder $response, HttpRequest $request): string {
+        if(!$this->authInfo->loggedIn)
+            return Template::renderRaw('oauth2.login', [
+                'login_url' => $this->urls->format('auth-login'),
+                'register_url' => $this->urls->format('auth-register'),
+            ]);
+
+        return Template::renderRaw('oauth2.verify');
+    }
+
+    /**
+     * @return int|array{
+     *  error: 'auth'|'csrf'|'invalid'|'code'|'expired'|'approval'|'code'|'scope',
+     *  scope?: string,
+     *  reason?: string,
+     * }|array{
+     *  approval: 'approved'|'denied',
+     * }
+     */
+    #[HttpPost('/oauth2/verify')]
+    public function postVerify(HttpResponseBuilder $response, HttpRequest $request): int|array {
+        if(!($request->content instanceof FormHttpContent))
+            return 400;
+
+        // TODO: RATE LIMITING
+
+        if(!$this->authInfo->loggedIn)
+            return ['error' => 'auth'];
+
+        if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
+            return ['error' => 'csrf'];
+
+        $response->setHeader('X-CSRF-Token', CSRF::token());
+
+        $approve = (string)$request->content->getParam('approve');
+        if(!in_array($approve, ['yes', 'no']))
+            return ['error' => 'invalid'];
+
+        try {
+            $deviceInfo = $this->oauth2Ctx->devices->getDeviceInfo(
+                userCode: (string)$request->content->getParam('code')
+            );
+        } catch(RuntimeException $ex) {
+            return ['error' => 'code'];
+        }
+
+        if($deviceInfo->expired)
+            return ['error' => 'expired'];
+
+        if(!$deviceInfo->pending)
+            return ['error' => 'approval'];
+
+        $approved = $approve === 'yes';
+
+        $error = null;
+        if($approved) {
+            try {
+                $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(
+                    appId: $deviceInfo->appId,
+                    deleted: false
+                );
+            } catch(RuntimeException $ex) {
+                return ['error' => 'code'];
+            }
+
+            $scopeInfos = $this->oauth2Ctx->appsCtx->handleScopeString($appInfo, $deviceInfo->scope);
+            foreach($scopeInfos as $scopeName => $scopeInfo) {
+                if(is_string($scopeInfo)) {
+                    $approved = false;
+                    $error = ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo];
+                    break;
+                }
+
+                $scope[] = $scopeInfo->getSummary();
+            }
+        }
+
+        $this->oauth2Ctx->devices->setDeviceApproval($deviceInfo, $approved, $this->authInfo->userInfo);
+
+        if($error !== null)
+            return $error;
+
+        return [
+            'approval' => $approved ? 'approved' : 'denied',
+        ];
+    }
+
+    /**
+     * @return array{
+     *  error: 'auth'|'csrf'|'code'|'expired'|'approval'|'scope',
+     *  scope?: string,
+     *  reason?: string,
+     * }|array{
+     *  req: array{
+     *   code: string,
+     *  },
+     *  app: array{
+     *   name: string,
+     *   summary: string,
+     *   trusted: bool,
+     *   links: array{ title: string, display: string, uri: string }[]
+     *  },
+     *  user: array{
+     *   name: string,
+     *   colour: string,
+     *   profile_uri: string,
+     *   avatar_uri: string,
+     *   guise?: array{
+     *    name: string,
+     *    colour: string,
+     *    profile_uri: string,
+     *    revert_uri: string,
+     *    avatar_uri: string,
+     *   },
+     *  },
+     *  scope: string[],
+     * }
+     */
+    #[HttpGet('/oauth2/resolve-verify')]
+    public function getResolveVerify(HttpResponseBuilder $response, HttpRequest $request) {
+        // TODO: RATE LIMITING
+
+        if(!$this->authInfo->loggedIn)
+            return ['error' => 'auth'];
+
+        if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
+            return ['error' => 'csrf'];
+
+        $response->setHeader('X-CSRF-Token', CSRF::token());
+
+        try {
+            $deviceInfo = $this->oauth2Ctx->devices->getDeviceInfo(userCode: (string)$request->getParam('code'));
+        } catch(RuntimeException $ex) {
+            return ['error' => 'code'];
+        }
+
+        if($deviceInfo->expired)
+            return ['error' => 'expired'];
+
+        if(!$deviceInfo->pending)
+            return ['error' => 'approval'];
+
+        try {
+            $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(
+                appId: $deviceInfo->appId,
+                deleted: false
+            );
+        } catch(RuntimeException $ex) {
+            return ['error' => 'code'];
+        }
+
+        $scope = [];
+        $scopeInfos = $this->oauth2Ctx->appsCtx->handleScopeString($appInfo, $deviceInfo->scope);
+        foreach($scopeInfos as $scopeName => $scopeInfo) {
+            if(is_string($scopeInfo))
+                return ['error' => 'scope', 'scope' => $scopeName, 'reason' => $scopeInfo];
+
+            $scope[] = $scopeInfo->getSummary();
+        }
+
+        $result = [
+            'req' => [
+                'code' => $deviceInfo->userCode,
+            ],
+            'app' => [
+                'name' => $appInfo->name,
+                'summary' => $appInfo->summary,
+                'trusted' => $appInfo->trusted,
+                'links' => [
+                    ['title' => 'Website', 'display' => $appInfo->websiteForDisplay, 'uri' => $appInfo->website],
+                ],
+            ],
+            'scope' => $scope,
+            'user' => [
+                'name' => $this->authInfo->userInfo->name,
+                'colour' => (string)$this->usersCtx->getUserColour($this->authInfo->userInfo),
+                'profile_uri' => $this->siteInfo->url . $this->urls->format('user-profile', ['user' => $this->authInfo->userInfo->id]),
+                'avatar_uri' => $this->siteInfo->url . $this->urls->format('user-avatar', ['user' => $this->authInfo->userInfo->id, 'res' => 120]),
+            ],
+        ];
+
+        if($this->authInfo->isImpersonating)
+            $result['user']['guise'] = [
+                'name' => $this->authInfo->realUserInfo->name,
+                'colour' => (string)$this->usersCtx->getUserColour($this->authInfo->realUserInfo),
+                'profile_uri' => $this->siteInfo->url . $this->urls->format('user-profile', ['user' => $this->authInfo->realUserInfo->id]),
+                'revert_uri' => $this->siteInfo->url . $this->urls->format('auth-revert', ['csrf' => CSRF::token()]),
+                'avatar_uri' => $this->siteInfo->url . $this->urls->format('user-avatar', ['user' => $this->authInfo->realUserInfo->id, 'res' => 60]),
+            ];
+
+        return $result;
+    }
+}
diff --git a/src/Profile/ProfileFieldsData.php b/src/Profile/ProfileFieldsData.php
index 6b07a168..25fed64e 100644
--- a/src/Profile/ProfileFieldsData.php
+++ b/src/Profile/ProfileFieldsData.php
@@ -43,7 +43,7 @@ class ProfileFieldsData {
 
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(ProfileFieldInfo::fromResult(...));
+        return $stmt->getResultIterator(ProfileFieldInfo::fromResult(...));
     }
 
     public function getField(string $fieldId): ProfileFieldInfo {
@@ -116,7 +116,7 @@ class ProfileFieldsData {
 
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(ProfileFieldFormatInfo::fromResult(...));
+        return $stmt->getResultIterator(ProfileFieldFormatInfo::fromResult(...));
     }
 
     public function getFieldFormat(string $formatId): ProfileFieldFormatInfo {
@@ -161,7 +161,7 @@ class ProfileFieldsData {
         $stmt->nextParameter($userInfo);
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(ProfileFieldValueInfo::fromResult(...));
+        return $stmt->getResultIterator(ProfileFieldValueInfo::fromResult(...));
     }
 
     public function getFieldValue(
diff --git a/src/Redirects/IncrementalRedirectsData.php b/src/Redirects/IncrementalRedirectsData.php
index 95c93539..7ebe2b9e 100644
--- a/src/Redirects/IncrementalRedirectsData.php
+++ b/src/Redirects/IncrementalRedirectsData.php
@@ -21,7 +21,7 @@ class IncrementalRedirectsData {
         $stmt = $this->cache->get($query);
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(IncrementalRedirectInfo::fromResult(...));
+        return $stmt->getResultIterator(IncrementalRedirectInfo::fromResult(...));
     }
 
     public const int INC_BY_ID = 1;
diff --git a/src/Redirects/NamedRedirectsData.php b/src/Redirects/NamedRedirectsData.php
index 9ad37790..bf5a5ee0 100644
--- a/src/Redirects/NamedRedirectsData.php
+++ b/src/Redirects/NamedRedirectsData.php
@@ -21,7 +21,7 @@ class NamedRedirectsData {
         $stmt = $this->cache->get($query);
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(NamedRedirectInfo::fromResult(...));
+        return $stmt->getResultIterator(NamedRedirectInfo::fromResult(...));
     }
 
     public const int NAMED_BY_ID = 1;
diff --git a/src/SharpChat/SharpChatRoutes.php b/src/SharpChat/SharpChatRoutes.php
index 3e54a6a3..60762e88 100644
--- a/src/SharpChat/SharpChatRoutes.php
+++ b/src/SharpChat/SharpChatRoutes.php
@@ -66,7 +66,7 @@ final class SharpChatRoutes implements RouteHandler {
 
     #[HttpGet('/_sockchat/login')]
     public function getLogin(HttpResponseBuilder $response, HttpRequest $request): void {
-        if(!$this->authInfo->isLoggedIn) {
+        if(!$this->authInfo->loggedIn) {
             $response->redirect($this->urls->format('auth-login'));
             return;
         }
diff --git a/src/TemplatingExtension.php b/src/TemplatingExtension.php
index 99eb101e..19e1c297 100644
--- a/src/TemplatingExtension.php
+++ b/src/TemplatingExtension.php
@@ -81,7 +81,7 @@ final class TemplatingExtension extends AbstractExtension {
             'menu' => [],
         ];
 
-        if($this->ctx->authInfo->isLoggedIn)
+        if($this->ctx->authInfo->loggedIn)
             $home['menu'][] = [
                 'title' => 'Members',
                 'url' => $this->ctx->urls->format('user-list'),
@@ -146,7 +146,7 @@ final class TemplatingExtension extends AbstractExtension {
     public function getUserMenu(bool $inBroomCloset, string $manageUrl = ''): array {
         $menu = [];
 
-        if($this->ctx->authInfo->isLoggedIn) {
+        if($this->ctx->authInfo->loggedIn) {
             $userInfo = $this->ctx->authInfo->userInfo;
             $globalPerms = $this->ctx->authInfo->getPerms('global');
 
@@ -214,7 +214,7 @@ final class TemplatingExtension extends AbstractExtension {
     /** @return array<string, array<string, string>> */
     public function getManageMenu(): array {
         $globalPerms = $this->ctx->authInfo->getPerms('global');
-        if(!$this->ctx->authInfo->isLoggedIn || !$globalPerms->check(Perm::G_IS_JANITOR))
+        if(!$this->ctx->authInfo->loggedIn || !$globalPerms->check(Perm::G_IS_JANITOR))
             return [];
 
         $menu = [
diff --git a/src/Users/BansData.php b/src/Users/BansData.php
index 8307631d..4a8f7e97 100644
--- a/src/Users/BansData.php
+++ b/src/Users/BansData.php
@@ -94,7 +94,7 @@ class BansData {
             $pagination->addToStatement($stmt);
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(BanInfo::fromResult(...));
+        return $stmt->getResultIterator(BanInfo::fromResult(...));
     }
 
     public function getBan(string $banId): BanInfo {
diff --git a/src/Users/ModNotesData.php b/src/Users/ModNotesData.php
index 968bc05d..e0acce06 100644
--- a/src/Users/ModNotesData.php
+++ b/src/Users/ModNotesData.php
@@ -86,7 +86,7 @@ class ModNotesData {
             $pagination->addToStatement($stmt);
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(ModNoteInfo::fromResult(...));
+        return $stmt->getResultIterator(ModNoteInfo::fromResult(...));
     }
 
     public function getNote(string $noteId): ModNoteInfo {
diff --git a/src/Users/RolesData.php b/src/Users/RolesData.php
index 36d1ae0e..d559aff4 100644
--- a/src/Users/RolesData.php
+++ b/src/Users/RolesData.php
@@ -86,7 +86,7 @@ class RolesData {
             $pagination->addToStatement($stmt);
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(RoleInfo::fromResult(...));
+        return $stmt->getResultIterator(RoleInfo::fromResult(...));
     }
 
     public function getRole(string $roleId): RoleInfo {
diff --git a/src/Users/UsersData.php b/src/Users/UsersData.php
index bc0c3a27..0a1de565 100644
--- a/src/Users/UsersData.php
+++ b/src/Users/UsersData.php
@@ -206,7 +206,7 @@ class UsersData {
             $pagination->addToStatement($stmt);
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(UserInfo::fromResult(...));
+        return $stmt->getResultIterator(UserInfo::fromResult(...));
     }
 
     public const GET_USER_ID = 0x01;
diff --git a/src/Users/WarningsData.php b/src/Users/WarningsData.php
index e36f94a1..f5ea77ad 100644
--- a/src/Users/WarningsData.php
+++ b/src/Users/WarningsData.php
@@ -102,7 +102,7 @@ class WarningsData {
             $pagination->addToStatement($stmt);
         $stmt->execute();
 
-        return $stmt->getResult()->getIterator(WarningInfo::fromResult(...));
+        return $stmt->getResultIterator(WarningInfo::fromResult(...));
     }
 
     public function getWarning(string $warnId): WarningInfo {
diff --git a/templates/_layout/header.twig b/templates/_layout/header.twig
index e5117c15..87549d97 100644
--- a/templates/_layout/header.twig
+++ b/templates/_layout/header.twig
@@ -56,7 +56,7 @@
                 </a>
             {% endfor %}
 
-            {% if globals.auth_info.isLoggedIn %}
+            {% if globals.auth_info.loggedIn %}
                 {% set user_info = globals.auth_info.userInfo %}
                 <a href="{{ url('user-profile', {'user': user_info.id}) }}" class="avatar header__desktop__user__avatar" title="{{ user_info.name }}">
                     {{ avatar(user_info.id, 60, user_info.name) }}
@@ -80,7 +80,7 @@
             </a>
 
             <label class="header__mobile__icon header__mobile__avatar" for="toggle-mobile-header">
-                {% if globals.auth_info.isLoggedIn %}
+                {% if globals.auth_info.loggedIn %}
                     {% set user_info = globals.auth_info.userInfo %}
                     {{ avatar(user_info.id, 40, user_info.name) }}
                 {% else %}
diff --git a/templates/oauth2/authorise.twig b/templates/oauth2/authorise.twig
new file mode 100644
index 00000000..a1391f19
--- /dev/null
+++ b/templates/oauth2/authorise.twig
@@ -0,0 +1,29 @@
+{% extends 'oauth2/master.twig' %}
+
+{% set body_header_icon = 'wait' %}
+{% set body_header_text = 'Loading...' %}
+{% set body_title = 'Authorisation Request' %}
+
+{% block body_content %}
+    <div class="js-loading"></div>
+
+    <div class="js-authorise-error hidden">
+        <div class="oauth2-errorbody">
+            <p class="js-authorise-error-text"></p>
+        </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 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 %}
diff --git a/templates/oauth2/login.twig b/templates/oauth2/login.twig
new file mode 100644
index 00000000..2faa8f15
--- /dev/null
+++ b/templates/oauth2/login.twig
@@ -0,0 +1,62 @@
+{% extends 'oauth2/master.twig' %}
+
+{% set body_header_icon = 'login' %}
+{% set body_header_text = 'Not logged in' %}
+{% set body_title = 'Authorisation Request' %}
+
+{% block body_content %}
+    <div class="oauth2-authorise-requesting">
+        {% if app is defined and not app.isTrusted %}
+            <p>A third-party application is requesting permission to access your account.</p>
+        {% endif %}
+
+        <p>You must be logged in to authorise applications. <a href="{{ login_url }}" target="_blank">Log in</a> or <a href="{{ register_url }}" target="_blank">create an account</a> and reload this page to try again.</p>
+
+        {% if app is defined and app.isTrusted %}
+            <p>You will be redirected to the following application after logging in.</p>
+        {% endif %}
+    </div>
+
+    {% if app is defined %}
+        <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>
+        </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>
+            </div>
+        </div>
+    {% else %}
+        <div class="oauth2-authorise-device">
+            <p>More details about the application will be displayed once you're logged in.</p>
+        </div>
+    {% endif %}
+{% endblock %}
diff --git a/templates/oauth2/master.twig b/templates/oauth2/master.twig
new file mode 100644
index 00000000..cecb0cde
--- /dev/null
+++ b/templates/oauth2/master.twig
@@ -0,0 +1,39 @@
+{% extends 'html.twig' %}
+
+{% set html_title = (title is defined ? (title ~ ' :: ') : '') ~ globals.site_info.name %}
+
+{% block html_head %}
+    <link href="{{ asset('oauth2.css') }}" rel="stylesheet">
+{% endblock %}
+
+{% block html_body %}
+    <div class="oauth2-wrapper">
+        <div class="oauth2-dialog">
+            <div class="oauth2-dialog-body">
+                {% block body %}
+                    {% block body_header %}
+                        <header class="oauth2-header js-oauth2-header">
+                            {% 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>
+                        </header>
+                    {% endblock %}
+
+                    <div class="oauth2-body">
+                        <div class="oauth2-banner">
+                            <div class="oauth2-banner-text">{{ body_title|default('') }}</div>
+                            <div class="oauth2-banner-logo">{{ globals.site_info.name }}</div>
+                        </div>
+
+                        {% block body_content %}{% endblock %}
+                    </div>
+                {% endblock %}
+            </div>
+        </div>
+    </div>
+    <script src="{{ asset('oauth2.js') }}"></script>
+{% endblock %}
diff --git a/templates/oauth2/verify.twig b/templates/oauth2/verify.twig
new file mode 100644
index 00000000..906a2837
--- /dev/null
+++ b/templates/oauth2/verify.twig
@@ -0,0 +1,58 @@
+{% extends 'oauth2/master.twig' %}
+
+{% set body_header_icon = 'code' %}
+{% set body_header_text = 'Code authorisation' %}
+{% set body_title = 'Authorisation Request' %}
+
+{% block body_content %}
+    <div class="js-loading"></div>
+
+    <form class="js-verify-code hidden">
+        <div class="oauth2-authorise-requesting">
+            <p>A third-party application that cannot display websites on its own is requesting permission to access your account.</p>
+            <p>Enter the code displayed on the device or application in the box below to continue authorisation.</p>
+        </div>
+
+        <div class="oauth2-device-form">
+            <input type="text" class="oauth2-device-code js-device-code" name="code" placeholder="XXX-XXX-XXX" autofocus>
+        </div>
+
+        <div class="oauth2-authorise-buttons">
+            <button class="oauth2-authorise-button">Next</button>
+        </div>
+    </form>
+
+    <form class="js-verify-authorise hidden">
+        <div class="oauth2-authorise-requesting">
+            <p>A third-party application is requesting permission to access your account.</p>
+        </div>
+
+        <div class="js-verify-authorise-info"></div>
+        <div class="js-verify-authorise-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>
+
+    <div class="js-verify-approved hidden">
+        <div class="oauth2-approval">
+            <div class="oauth2-approval-icon oauth2-approval-icon-approved"></div>
+            <div class="oauth2-approval-header">Approved!</div>
+            <div class="oauth2-approval-text">
+                <p>You have approved the authorisation request. You should now be signed in on the target device or application.</p>
+            </div>
+        </div>
+    </div>
+
+    <div class="js-verify-denied hidden">
+        <div class="oauth2-approval">
+            <div class="oauth2-approval-icon oauth2-approval-icon-denied"></div>
+            <div class="oauth2-approval-header">Denied!</div>
+            <div class="oauth2-approval-text">
+                <p>You have denied the authorisation request. Please return to the target device or application and follow displayed instructions.</p>
+            </div>
+        </div>
+    </div>
+{% endblock %}