diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8099d1800ac3f0c6fac0c502d8e867c08655356c..8bd5078f33a4ad8a9679f09780a61348a04e8868 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
 
 ### Added
 
+- Correct OAuth2 access handling
 - Use ALS for request-agnostic context injection
 - Add performance monitoring to queue jobs
 - Use queue for sending password reset emails
diff --git a/database/migrations/20210000000005-create-oidc-entities-table.js b/database/migrations/20210000000005-create-oidc-entities-table.js
new file mode 100644
index 0000000000000000000000000000000000000000..d53a279b325359efd7a5497c98107df65e9eeefa
--- /dev/null
+++ b/database/migrations/20210000000005-create-oidc-entities-table.js
@@ -0,0 +1,71 @@
+module.exports = {
+	up: (migration, Types) => {
+		return migration.sequelize.transaction(async t => {
+			await migration.createTable('oidc_entities', {
+				id: {
+					type: Types.TEXT,
+					primaryKey: true,
+					allowNull: false,
+				},
+				related_user_id: {
+					type: Types.UUID,
+					allowNull: true,
+					onDelete: 'CASCADE',
+					onUpdate: 'CASCADE',
+					references: {
+						model: 'users',
+						key: 'id',
+					},
+				},
+				grant_id: {
+					type: Types.TEXT,
+					allowNull: true,
+					index: true,
+				},
+				user_code: {
+					type: Types.TEXT,
+					allowNull: true,
+					index: true,
+				},
+				uid: {
+					type: Types.TEXT,
+					allowNull: true,
+					index: true,
+				},
+				data: {
+					type: Types.JSONB,
+					allowNull: false,
+				},
+				expires_at: {
+					type: Types.DATE,
+					allowNull: true,
+				},
+				consumed_at: {
+					type: Types.DATE,
+					allowNull: true,
+				},
+				meta: {
+					type: Types.JSONB,
+					defaultValue: {},
+					allowNull: false,
+				},
+				created_at: {
+					type: Types.DATE,
+					defaultValue: Types.fn('now'),
+					allowNull: false,
+				},
+				updated_at: {
+					type: Types.DATE,
+					defaultValue: Types.fn('now'),
+					allowNull: false,
+				},
+			}, { transaction: t })
+			await migration.addIndex('oidc_entities', ['grant_id'], { transaction: t })
+			await migration.addIndex('oidc_entities', ['user_code'], { transaction: t })
+			await migration.addIndex('oidc_entities', ['uid'], { transaction: t })
+		})
+	},
+	down: (migration, Types) => {
+		return migration.dropTable('oidc_entities')
+	},
+}
diff --git a/package-lock.json b/package-lock.json
index a2d9da7a5c3db490c61358954473d62e9a429355..15800f41045b724d1448575117ed9364f3c2ab16 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -31,6 +31,7 @@
         "jose": "^3.6.1",
         "koa": "^2.13.0",
         "koa-bodyparser": "^4.3.0",
+        "koa-compose": "^4.1.0",
         "koa-compress": "^5.0.1",
         "koa-csrf": "^3.0.8",
         "koa-etag": "^3.0.0",
@@ -46,6 +47,7 @@
         "node-fetch": "^2.6.1",
         "nodemailer": "^6.4.17",
         "oauth2-server": "^3.1.1",
+        "oidc-provider": "^7.10.1",
         "pg": "^8.3.0",
         "pg-hstore": "^2.3.3",
         "pluralize": "^8.0.0",
@@ -1590,6 +1592,17 @@
         "@babel/types": "^7.3.0"
       }
     },
+    "node_modules/@types/cacheable-request": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.2.tgz",
+      "integrity": "sha512-B3xVo+dlKM6nnKTcmm5ZtY/OL8bOAOd2Olee9M1zft65ox50OzjEHW91sDiU9j6cvW8Ejg1/Qkf4xd2kugApUA==",
+      "dependencies": {
+        "@types/http-cache-semantics": "*",
+        "@types/keyv": "*",
+        "@types/node": "*",
+        "@types/responselike": "*"
+      }
+    },
     "node_modules/@types/color-name": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
@@ -1605,6 +1618,11 @@
         "@types/node": "*"
       }
     },
+    "node_modules/@types/http-cache-semantics": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz",
+      "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ=="
+    },
     "node_modules/@types/istanbul-lib-coverage": {
       "version": "2.0.3",
       "resolved": "https://npm.lcr.gr/@types%2fistanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz",
@@ -1629,6 +1647,14 @@
         "@types/istanbul-lib-report": "*"
       }
     },
+    "node_modules/@types/keyv": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.3.tgz",
+      "integrity": "sha512-FXCJgyyN3ivVgRoml4h94G/p3kY+u/B86La+QptcqJaWtBWtmc6TtkNfS40n9bIvyLteHh7zXOtgbobORKPbDg==",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/node": {
       "version": "14.0.22",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-14.0.22.tgz",
@@ -1646,6 +1672,14 @@
       "integrity": "sha512-6gOkRe7OIioWAXfnO/2lFiv+SJichKVSys1mSsgyrYHSEjk8Ctv4tSR/Odvnu+HWlH2C8j53dahU03XmQdd5fA==",
       "dev": true
     },
+    "node_modules/@types/responselike": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz",
+      "integrity": "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
     "node_modules/@types/stack-utils": {
       "version": "2.0.0",
       "resolved": "https://npm.lcr.gr/@types%2fstack-utils/-/stack-utils-2.0.0.tgz",
@@ -1968,6 +2002,11 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/async": {
+      "version": "0.9.2",
+      "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz",
+      "integrity": "sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0="
+    },
     "node_modules/asynckit": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -2613,6 +2652,14 @@
         "node": ">= 6.0.0"
       }
     },
+    "node_modules/cacheable-lookup": {
+      "version": "6.0.4",
+      "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-6.0.4.tgz",
+      "integrity": "sha512-mbcDEZCkv2CZF4G01kr8eBd/5agkt9oCqz75tJMSIsquvRZ2sL6Hi5zGVKi/0OSC9oO1GHfJ2AV0ZIOY9vye0A==",
+      "engines": {
+        "node": ">=10.6.0"
+      }
+    },
     "node_modules/cacheable-request": {
       "version": "6.1.0",
       "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz",
@@ -2850,7 +2897,6 @@
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz",
       "integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=",
-      "dev": true,
       "dependencies": {
         "mimic-response": "^1.0.0"
       }
@@ -3283,10 +3329,9 @@
       }
     },
     "node_modules/debug": {
-      "version": "4.2.0",
-      "resolved": "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz",
-      "integrity": "sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg==",
-      "deprecated": "Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797)",
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
+      "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
       "dependencies": {
         "ms": "2.1.2"
       },
@@ -3647,6 +3692,20 @@
       "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
       "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0="
     },
+    "node_modules/ejs": {
+      "version": "3.1.6",
+      "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.6.tgz",
+      "integrity": "sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw==",
+      "dependencies": {
+        "jake": "^10.6.1"
+      },
+      "bin": {
+        "ejs": "bin/cli.js"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/emittery": {
       "version": "0.7.2",
       "resolved": "https://npm.lcr.gr/emittery/-/emittery-0.7.2.tgz",
@@ -4173,6 +4232,14 @@
       "resolved": "https://npm.lcr.gr/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
       "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
     },
+    "node_modules/filelist": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.2.tgz",
+      "integrity": "sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ==",
+      "dependencies": {
+        "minimatch": "^3.0.4"
+      }
+    },
     "node_modules/fill-range": {
       "version": "7.0.1",
       "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -4897,8 +4964,7 @@
     "node_modules/http-cache-semantics": {
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz",
-      "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==",
-      "dev": true
+      "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ=="
     },
     "node_modules/http-deceiver": {
       "version": "1.2.7",
@@ -4965,6 +5031,18 @@
         "npm": ">=1.3.7"
       }
     },
+    "node_modules/http2-wrapper": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
+      "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
+      "dependencies": {
+        "quick-lru": "^5.1.1",
+        "resolve-alpn": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=10.19.0"
+      }
+    },
     "node_modules/https-proxy-agent": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz",
@@ -5562,6 +5640,23 @@
         "node": ">=8"
       }
     },
+    "node_modules/jake": {
+      "version": "10.8.2",
+      "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.2.tgz",
+      "integrity": "sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A==",
+      "dependencies": {
+        "async": "0.9.x",
+        "chalk": "^2.4.2",
+        "filelist": "^1.0.1",
+        "minimatch": "^3.0.4"
+      },
+      "bin": {
+        "jake": "bin/cli.js"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
     "node_modules/jest": {
       "version": "26.6.3",
       "resolved": "https://npm.lcr.gr/jest/-/jest-26.6.3.tgz",
@@ -7852,18 +7947,18 @@
       }
     },
     "node_modules/koa": {
-      "version": "2.13.0",
-      "resolved": "https://registry.npmjs.org/koa/-/koa-2.13.0.tgz",
-      "integrity": "sha512-i/XJVOfPw7npbMv67+bOeXr3gPqOAw6uh5wFyNs3QvJ47tUx3M3V9rIE0//WytY42MKz4l/MXKyGkQ2LQTfLUQ==",
+      "version": "2.13.4",
+      "resolved": "https://registry.npmjs.org/koa/-/koa-2.13.4.tgz",
+      "integrity": "sha512-43zkIKubNbnrULWlHdN5h1g3SEKXOEzoAlRsHOTFpnlDu8JlAOZSMJBLULusuXRequboiwJcj5vtYXKB3k7+2g==",
       "dependencies": {
         "accepts": "^1.3.5",
         "cache-content-type": "^1.0.0",
         "content-disposition": "~0.5.2",
         "content-type": "^1.0.4",
         "cookies": "~0.8.0",
-        "debug": "~3.1.0",
+        "debug": "^4.3.2",
         "delegates": "^1.0.0",
-        "depd": "^1.1.2",
+        "depd": "^2.0.0",
         "destroy": "^1.0.4",
         "encodeurl": "^1.0.2",
         "escape-html": "^1.0.3",
@@ -7872,7 +7967,7 @@
         "http-errors": "^1.6.3",
         "is-generator-function": "^1.0.7",
         "koa-compose": "^4.1.0",
-        "koa-convert": "^1.2.0",
+        "koa-convert": "^2.0.0",
         "on-finished": "^2.3.0",
         "only": "~0.0.2",
         "parseurl": "^1.3.2",
@@ -7925,23 +8020,15 @@
       }
     },
     "node_modules/koa-convert": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-1.2.0.tgz",
-      "integrity": "sha1-2kCHXfSd4FOQmNFwC1CCDOvNIdA=",
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz",
+      "integrity": "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==",
       "dependencies": {
         "co": "^4.6.0",
-        "koa-compose": "^3.0.0"
+        "koa-compose": "^4.1.0"
       },
       "engines": {
-        "node": ">= 4"
-      }
-    },
-    "node_modules/koa-convert/node_modules/koa-compose": {
-      "version": "3.2.1",
-      "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-3.2.1.tgz",
-      "integrity": "sha1-qFzLQLfZhtjlo0Wzoazo6rz1Tec=",
-      "dependencies": {
-        "any-promise": "^1.1.0"
+        "node": ">= 10"
       }
     },
     "node_modules/koa-csrf": {
@@ -8060,18 +8147,13 @@
         "ms": "^2.1.1"
       }
     },
-    "node_modules/koa/node_modules/debug": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
-      "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
-      "dependencies": {
-        "ms": "2.0.0"
-      }
-    },
-    "node_modules/koa/node_modules/ms": {
+    "node_modules/koa/node_modules/depd": {
       "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "engines": {
+        "node": ">= 0.8"
+      }
     },
     "node_modules/latest-version": {
       "version": "5.1.0",
@@ -8341,7 +8423,6 @@
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
       "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
-      "dev": true,
       "engines": {
         "node": ">=4"
       }
@@ -8480,6 +8561,17 @@
         "thenify-all": "^1.0.0"
       }
     },
+    "node_modules/nanoid": {
+      "version": "3.1.30",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.30.tgz",
+      "integrity": "sha512-zJpuPDwOv8D2zq2WRoMe1HsfZthVewpel9CAvTfc/2mBD1uUT/agc5f7GHGWXlYkFvi1mVxe4IjvP2HNrop7nQ==",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
     "node_modules/nanomatch": {
       "version": "1.2.13",
       "resolved": "https://npm.lcr.gr/nanomatch/-/nanomatch-1.2.13.tgz",
@@ -8881,6 +8973,238 @@
       "resolved": "https://npm.lcr.gr/obuf/-/obuf-1.1.2.tgz",
       "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="
     },
+    "node_modules/oidc-provider": {
+      "version": "7.10.1",
+      "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-7.10.1.tgz",
+      "integrity": "sha512-SRFVePq/ki76SbKB/vx7ach6mgc6iof2dtYOKMA88f3i1gZ6XKbqqxqyEVAj4n9L13nssUWJE9O4AyN8M0bNKA==",
+      "dependencies": {
+        "@koa/cors": "^3.1.0",
+        "cacheable-lookup": "^6.0.1",
+        "debug": "^4.3.2",
+        "ejs": "^3.1.6",
+        "got": "^11.8.2",
+        "jose": "^4.1.4",
+        "jsesc": "^3.0.2",
+        "koa": "^2.13.3",
+        "koa-compose": "^4.1.0",
+        "nanoid": "^3.1.28",
+        "object-hash": "^2.2.0",
+        "oidc-token-hash": "^5.0.1",
+        "paseto2": "npm:paseto@^2.1.3",
+        "quick-lru": "^5.1.1",
+        "raw-body": "^2.4.1"
+      },
+      "engines": {
+        "node": "^12.19.0 || ^14.15.0 || ^16.13.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/panva"
+      },
+      "optionalDependencies": {
+        "paseto3": "npm:paseto@^3.0.0"
+      }
+    },
+    "node_modules/oidc-provider/node_modules/@sindresorhus/is": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.2.0.tgz",
+      "integrity": "sha512-VkE3KLBmJwcCaVARtQpfuKcKv8gcBmUubrfHGF84dXuuW6jgsRYxPtzcIhPyK9WAPpRt2/xY6zkD9MnRaJzSyw==",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/is?sponsor=1"
+      }
+    },
+    "node_modules/oidc-provider/node_modules/@szmarczak/http-timer": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
+      "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
+      "dependencies": {
+        "defer-to-connect": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/oidc-provider/node_modules/cacheable-request": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.2.tgz",
+      "integrity": "sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==",
+      "dependencies": {
+        "clone-response": "^1.0.2",
+        "get-stream": "^5.1.0",
+        "http-cache-semantics": "^4.0.0",
+        "keyv": "^4.0.0",
+        "lowercase-keys": "^2.0.0",
+        "normalize-url": "^6.0.1",
+        "responselike": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/oidc-provider/node_modules/decompress-response": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+      "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+      "dependencies": {
+        "mimic-response": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/oidc-provider/node_modules/defer-to-connect": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
+      "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/oidc-provider/node_modules/get-stream": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
+      "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+      "dependencies": {
+        "pump": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/oidc-provider/node_modules/got": {
+      "version": "11.8.2",
+      "resolved": "https://registry.npmjs.org/got/-/got-11.8.2.tgz",
+      "integrity": "sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ==",
+      "dependencies": {
+        "@sindresorhus/is": "^4.0.0",
+        "@szmarczak/http-timer": "^4.0.5",
+        "@types/cacheable-request": "^6.0.1",
+        "@types/responselike": "^1.0.0",
+        "cacheable-lookup": "^5.0.3",
+        "cacheable-request": "^7.0.1",
+        "decompress-response": "^6.0.0",
+        "http2-wrapper": "^1.0.0-beta.5.2",
+        "lowercase-keys": "^2.0.0",
+        "p-cancelable": "^2.0.0",
+        "responselike": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10.19.0"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/got?sponsor=1"
+      }
+    },
+    "node_modules/oidc-provider/node_modules/got/node_modules/cacheable-lookup": {
+      "version": "5.0.4",
+      "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
+      "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==",
+      "engines": {
+        "node": ">=10.6.0"
+      }
+    },
+    "node_modules/oidc-provider/node_modules/jose": {
+      "version": "4.3.6",
+      "resolved": "https://registry.npmjs.org/jose/-/jose-4.3.6.tgz",
+      "integrity": "sha512-A/JgZGUerqG2IMuxkUDBtZ4aTxg/l1Y+pt/QAAYiRAR3EFlxIE0Su0xdpB8tQcPZK5eudB7g1PHCZ5uHatbY+g==",
+      "funding": {
+        "url": "https://github.com/sponsors/panva"
+      }
+    },
+    "node_modules/oidc-provider/node_modules/jsesc": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz",
+      "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==",
+      "bin": {
+        "jsesc": "bin/jsesc"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/oidc-provider/node_modules/json-buffer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+      "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="
+    },
+    "node_modules/oidc-provider/node_modules/keyv": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.0.4.tgz",
+      "integrity": "sha512-vqNHbAc8BBsxk+7QBYLW0Y219rWcClspR6WSeoHYKG5mnsSoOH+BL1pWq02DDCVdvvuUny5rkBlzMRzoqc+GIg==",
+      "dependencies": {
+        "json-buffer": "3.0.1"
+      }
+    },
+    "node_modules/oidc-provider/node_modules/lowercase-keys": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
+      "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/oidc-provider/node_modules/mimic-response": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+      "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/oidc-provider/node_modules/normalize-url": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
+      "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/oidc-provider/node_modules/object-hash": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
+      "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/oidc-provider/node_modules/p-cancelable": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
+      "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/oidc-provider/node_modules/responselike": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.0.tgz",
+      "integrity": "sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==",
+      "dependencies": {
+        "lowercase-keys": "^2.0.0"
+      }
+    },
+    "node_modules/oidc-token-hash": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz",
+      "integrity": "sha512-EvoOtz6FIEBzE+9q253HsLCVRiK/0doEJ2HCvvqMQb3dHZrP3WlJKYtJ55CRTw4jmYomzH4wkPuCj/I3ZvpKxQ==",
+      "engines": {
+        "node": "^10.13.0 || >=12.0.0"
+      }
+    },
     "node_modules/on-finished": {
       "version": "2.3.0",
       "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@@ -9112,6 +9436,31 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/paseto2": {
+      "name": "paseto",
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/paseto/-/paseto-2.1.3.tgz",
+      "integrity": "sha512-BNkbvr0ZFDbh3oV13QzT5jXIu8xpFc9r0o5mvWBhDU1GBkVt1IzHK1N6dcYmN7XImrUmPQ0HCUXmoe2WPo8xsg==",
+      "engines": {
+        "node": "^12.19.0 || >=14.15.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/panva"
+      }
+    },
+    "node_modules/paseto3": {
+      "name": "paseto",
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/paseto/-/paseto-3.1.0.tgz",
+      "integrity": "sha512-oVSKoCH89M0WU3I+13NoCP9wGRel0BlQumwxsDZPk1yJtqS76PWKRM7vM9D4bz4PcScT0aIiAipC7lW6hSgkBQ==",
+      "optional": true,
+      "engines": {
+        "node": ">=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/panva"
+      }
+    },
     "node_modules/passthrough-counter": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/passthrough-counter/-/passthrough-counter-1.0.0.tgz",
@@ -9619,6 +9968,17 @@
       "resolved": "https://npm.lcr.gr/quick-format-unescaped/-/quick-format-unescaped-3.0.3.tgz",
       "integrity": "sha512-dy1yjycmn9blucmJLXOfZDx1ikZJUi6E8bBZLnhPG5gBrVhHXx2xVyqqgKBubVNEXmx51dBACMHpoMQK/N/AXQ=="
     },
+    "node_modules/quick-lru": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
+      "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/random-bytes": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
@@ -10041,6 +10401,11 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/resolve-alpn": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
+      "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="
+    },
     "node_modules/resolve-cwd": {
       "version": "3.0.0",
       "resolved": "https://npm.lcr.gr/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
@@ -13555,6 +13920,17 @@
         "@babel/types": "^7.3.0"
       }
     },
+    "@types/cacheable-request": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.2.tgz",
+      "integrity": "sha512-B3xVo+dlKM6nnKTcmm5ZtY/OL8bOAOd2Olee9M1zft65ox50OzjEHW91sDiU9j6cvW8Ejg1/Qkf4xd2kugApUA==",
+      "requires": {
+        "@types/http-cache-semantics": "*",
+        "@types/keyv": "*",
+        "@types/node": "*",
+        "@types/responselike": "*"
+      }
+    },
     "@types/color-name": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",
@@ -13570,6 +13946,11 @@
         "@types/node": "*"
       }
     },
+    "@types/http-cache-semantics": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz",
+      "integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ=="
+    },
     "@types/istanbul-lib-coverage": {
       "version": "2.0.3",
       "resolved": "https://npm.lcr.gr/@types%2fistanbul-lib-coverage/-/istanbul-lib-coverage-2.0.3.tgz",
@@ -13594,6 +13975,14 @@
         "@types/istanbul-lib-report": "*"
       }
     },
+    "@types/keyv": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.3.tgz",
+      "integrity": "sha512-FXCJgyyN3ivVgRoml4h94G/p3kY+u/B86La+QptcqJaWtBWtmc6TtkNfS40n9bIvyLteHh7zXOtgbobORKPbDg==",
+      "requires": {
+        "@types/node": "*"
+      }
+    },
     "@types/node": {
       "version": "14.0.22",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-14.0.22.tgz",
@@ -13611,6 +14000,14 @@
       "integrity": "sha512-6gOkRe7OIioWAXfnO/2lFiv+SJichKVSys1mSsgyrYHSEjk8Ctv4tSR/Odvnu+HWlH2C8j53dahU03XmQdd5fA==",
       "dev": true
     },
+    "@types/responselike": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz",
+      "integrity": "sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==",
+      "requires": {
+        "@types/node": "*"
+      }
+    },
     "@types/stack-utils": {
       "version": "2.0.0",
       "resolved": "https://npm.lcr.gr/@types%2fstack-utils/-/stack-utils-2.0.0.tgz",
@@ -13869,6 +14266,11 @@
       "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=",
       "dev": true
     },
+    "async": {
+      "version": "0.9.2",
+      "resolved": "https://registry.npmjs.org/async/-/async-0.9.2.tgz",
+      "integrity": "sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0="
+    },
     "asynckit": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -14389,6 +14791,11 @@
         "ylru": "^1.2.0"
       }
     },
+    "cacheable-lookup": {
+      "version": "6.0.4",
+      "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-6.0.4.tgz",
+      "integrity": "sha512-mbcDEZCkv2CZF4G01kr8eBd/5agkt9oCqz75tJMSIsquvRZ2sL6Hi5zGVKi/0OSC9oO1GHfJ2AV0ZIOY9vye0A=="
+    },
     "cacheable-request": {
       "version": "6.1.0",
       "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz",
@@ -14589,7 +14996,6 @@
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz",
       "integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=",
-      "dev": true,
       "requires": {
         "mimic-response": "^1.0.0"
       }
@@ -14954,9 +15360,9 @@
       }
     },
     "debug": {
-      "version": "4.2.0",
-      "resolved": "https://registry.npmjs.org/debug/-/debug-4.2.0.tgz",
-      "integrity": "sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg==",
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
+      "integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
       "requires": {
         "ms": "2.1.2"
       }
@@ -15253,6 +15659,14 @@
       "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
       "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0="
     },
+    "ejs": {
+      "version": "3.1.6",
+      "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.6.tgz",
+      "integrity": "sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw==",
+      "requires": {
+        "jake": "^10.6.1"
+      }
+    },
     "emittery": {
       "version": "0.7.2",
       "resolved": "https://npm.lcr.gr/emittery/-/emittery-0.7.2.tgz",
@@ -15688,6 +16102,14 @@
       "resolved": "https://npm.lcr.gr/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
       "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
     },
+    "filelist": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.2.tgz",
+      "integrity": "sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ==",
+      "requires": {
+        "minimatch": "^3.0.4"
+      }
+    },
     "fill-range": {
       "version": "7.0.1",
       "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -16247,8 +16669,7 @@
     "http-cache-semantics": {
       "version": "4.1.0",
       "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz",
-      "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==",
-      "dev": true
+      "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ=="
     },
     "http-deceiver": {
       "version": "1.2.7",
@@ -16304,6 +16725,15 @@
         "sshpk": "^1.7.0"
       }
     },
+    "http2-wrapper": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
+      "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==",
+      "requires": {
+        "quick-lru": "^5.1.1",
+        "resolve-alpn": "^1.0.0"
+      }
+    },
     "https-proxy-agent": {
       "version": "5.0.0",
       "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz",
@@ -16759,6 +17189,17 @@
         "istanbul-lib-report": "^3.0.0"
       }
     },
+    "jake": {
+      "version": "10.8.2",
+      "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.2.tgz",
+      "integrity": "sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A==",
+      "requires": {
+        "async": "0.9.x",
+        "chalk": "^2.4.2",
+        "filelist": "^1.0.1",
+        "minimatch": "^3.0.4"
+      }
+    },
     "jest": {
       "version": "26.6.3",
       "resolved": "https://npm.lcr.gr/jest/-/jest-26.6.3.tgz",
@@ -18504,18 +18945,18 @@
       "dev": true
     },
     "koa": {
-      "version": "2.13.0",
-      "resolved": "https://registry.npmjs.org/koa/-/koa-2.13.0.tgz",
-      "integrity": "sha512-i/XJVOfPw7npbMv67+bOeXr3gPqOAw6uh5wFyNs3QvJ47tUx3M3V9rIE0//WytY42MKz4l/MXKyGkQ2LQTfLUQ==",
+      "version": "2.13.4",
+      "resolved": "https://registry.npmjs.org/koa/-/koa-2.13.4.tgz",
+      "integrity": "sha512-43zkIKubNbnrULWlHdN5h1g3SEKXOEzoAlRsHOTFpnlDu8JlAOZSMJBLULusuXRequboiwJcj5vtYXKB3k7+2g==",
       "requires": {
         "accepts": "^1.3.5",
         "cache-content-type": "^1.0.0",
         "content-disposition": "~0.5.2",
         "content-type": "^1.0.4",
         "cookies": "~0.8.0",
-        "debug": "~3.1.0",
+        "debug": "^4.3.2",
         "delegates": "^1.0.0",
-        "depd": "^1.1.2",
+        "depd": "^2.0.0",
         "destroy": "^1.0.4",
         "encodeurl": "^1.0.2",
         "escape-html": "^1.0.3",
@@ -18524,7 +18965,7 @@
         "http-errors": "^1.6.3",
         "is-generator-function": "^1.0.7",
         "koa-compose": "^4.1.0",
-        "koa-convert": "^1.2.0",
+        "koa-convert": "^2.0.0",
         "on-finished": "^2.3.0",
         "only": "~0.0.2",
         "parseurl": "^1.3.2",
@@ -18533,18 +18974,10 @@
         "vary": "^1.1.2"
       },
       "dependencies": {
-        "debug": {
-          "version": "3.1.0",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
-          "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
-          "requires": {
-            "ms": "2.0.0"
-          }
-        },
-        "ms": {
+        "depd": {
           "version": "2.0.0",
-          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+          "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+          "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="
         }
       }
     },
@@ -18582,22 +19015,12 @@
       }
     },
     "koa-convert": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-1.2.0.tgz",
-      "integrity": "sha1-2kCHXfSd4FOQmNFwC1CCDOvNIdA=",
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/koa-convert/-/koa-convert-2.0.0.tgz",
+      "integrity": "sha512-asOvN6bFlSnxewce2e/DK3p4tltyfC4VM7ZwuTuepI7dEQVcvpyFuBcEARu1+Hxg8DIwytce2n7jrZtRlPrARA==",
       "requires": {
         "co": "^4.6.0",
-        "koa-compose": "^3.0.0"
-      },
-      "dependencies": {
-        "koa-compose": {
-          "version": "3.2.1",
-          "resolved": "https://registry.npmjs.org/koa-compose/-/koa-compose-3.2.1.tgz",
-          "integrity": "sha1-qFzLQLfZhtjlo0Wzoazo6rz1Tec=",
-          "requires": {
-            "any-promise": "^1.1.0"
-          }
-        }
+        "koa-compose": "^4.1.0"
       }
     },
     "koa-csrf": {
@@ -18918,8 +19341,7 @@
     "mimic-response": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz",
-      "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==",
-      "dev": true
+      "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="
     },
     "minimalistic-assert": {
       "version": "1.0.1",
@@ -19026,6 +19448,11 @@
         "thenify-all": "^1.0.0"
       }
     },
+    "nanoid": {
+      "version": "3.1.30",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.30.tgz",
+      "integrity": "sha512-zJpuPDwOv8D2zq2WRoMe1HsfZthVewpel9CAvTfc/2mBD1uUT/agc5f7GHGWXlYkFvi1mVxe4IjvP2HNrop7nQ=="
+    },
     "nanomatch": {
       "version": "1.2.13",
       "resolved": "https://npm.lcr.gr/nanomatch/-/nanomatch-1.2.13.tgz",
@@ -19343,6 +19770,165 @@
       "resolved": "https://npm.lcr.gr/obuf/-/obuf-1.1.2.tgz",
       "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="
     },
+    "oidc-provider": {
+      "version": "7.10.1",
+      "resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-7.10.1.tgz",
+      "integrity": "sha512-SRFVePq/ki76SbKB/vx7ach6mgc6iof2dtYOKMA88f3i1gZ6XKbqqxqyEVAj4n9L13nssUWJE9O4AyN8M0bNKA==",
+      "requires": {
+        "@koa/cors": "^3.1.0",
+        "cacheable-lookup": "^6.0.1",
+        "debug": "^4.3.2",
+        "ejs": "^3.1.6",
+        "got": "^11.8.2",
+        "jose": "^4.1.4",
+        "jsesc": "^3.0.2",
+        "koa": "^2.13.3",
+        "koa-compose": "^4.1.0",
+        "nanoid": "^3.1.28",
+        "object-hash": "^2.2.0",
+        "oidc-token-hash": "^5.0.1",
+        "paseto2": "npm:paseto@^2.1.3",
+        "paseto3": "npm:paseto@^3.0.0",
+        "quick-lru": "^5.1.1",
+        "raw-body": "^2.4.1"
+      },
+      "dependencies": {
+        "@sindresorhus/is": {
+          "version": "4.2.0",
+          "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.2.0.tgz",
+          "integrity": "sha512-VkE3KLBmJwcCaVARtQpfuKcKv8gcBmUubrfHGF84dXuuW6jgsRYxPtzcIhPyK9WAPpRt2/xY6zkD9MnRaJzSyw=="
+        },
+        "@szmarczak/http-timer": {
+          "version": "4.0.6",
+          "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
+          "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==",
+          "requires": {
+            "defer-to-connect": "^2.0.0"
+          }
+        },
+        "cacheable-request": {
+          "version": "7.0.2",
+          "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.2.tgz",
+          "integrity": "sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==",
+          "requires": {
+            "clone-response": "^1.0.2",
+            "get-stream": "^5.1.0",
+            "http-cache-semantics": "^4.0.0",
+            "keyv": "^4.0.0",
+            "lowercase-keys": "^2.0.0",
+            "normalize-url": "^6.0.1",
+            "responselike": "^2.0.0"
+          }
+        },
+        "decompress-response": {
+          "version": "6.0.0",
+          "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+          "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+          "requires": {
+            "mimic-response": "^3.1.0"
+          }
+        },
+        "defer-to-connect": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
+          "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg=="
+        },
+        "get-stream": {
+          "version": "5.2.0",
+          "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
+          "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+          "requires": {
+            "pump": "^3.0.0"
+          }
+        },
+        "got": {
+          "version": "11.8.2",
+          "resolved": "https://registry.npmjs.org/got/-/got-11.8.2.tgz",
+          "integrity": "sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ==",
+          "requires": {
+            "@sindresorhus/is": "^4.0.0",
+            "@szmarczak/http-timer": "^4.0.5",
+            "@types/cacheable-request": "^6.0.1",
+            "@types/responselike": "^1.0.0",
+            "cacheable-lookup": "^5.0.3",
+            "cacheable-request": "^7.0.1",
+            "decompress-response": "^6.0.0",
+            "http2-wrapper": "^1.0.0-beta.5.2",
+            "lowercase-keys": "^2.0.0",
+            "p-cancelable": "^2.0.0",
+            "responselike": "^2.0.0"
+          },
+          "dependencies": {
+            "cacheable-lookup": {
+              "version": "5.0.4",
+              "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz",
+              "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA=="
+            }
+          }
+        },
+        "jose": {
+          "version": "4.3.6",
+          "resolved": "https://registry.npmjs.org/jose/-/jose-4.3.6.tgz",
+          "integrity": "sha512-A/JgZGUerqG2IMuxkUDBtZ4aTxg/l1Y+pt/QAAYiRAR3EFlxIE0Su0xdpB8tQcPZK5eudB7g1PHCZ5uHatbY+g=="
+        },
+        "jsesc": {
+          "version": "3.0.2",
+          "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz",
+          "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g=="
+        },
+        "json-buffer": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+          "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="
+        },
+        "keyv": {
+          "version": "4.0.4",
+          "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.0.4.tgz",
+          "integrity": "sha512-vqNHbAc8BBsxk+7QBYLW0Y219rWcClspR6WSeoHYKG5mnsSoOH+BL1pWq02DDCVdvvuUny5rkBlzMRzoqc+GIg==",
+          "requires": {
+            "json-buffer": "3.0.1"
+          }
+        },
+        "lowercase-keys": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
+          "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="
+        },
+        "mimic-response": {
+          "version": "3.1.0",
+          "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+          "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="
+        },
+        "normalize-url": {
+          "version": "6.1.0",
+          "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
+          "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A=="
+        },
+        "object-hash": {
+          "version": "2.2.0",
+          "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
+          "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="
+        },
+        "p-cancelable": {
+          "version": "2.1.1",
+          "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
+          "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="
+        },
+        "responselike": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.0.tgz",
+          "integrity": "sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==",
+          "requires": {
+            "lowercase-keys": "^2.0.0"
+          }
+        }
+      }
+    },
+    "oidc-token-hash": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz",
+      "integrity": "sha512-EvoOtz6FIEBzE+9q253HsLCVRiK/0doEJ2HCvvqMQb3dHZrP3WlJKYtJ55CRTw4jmYomzH4wkPuCj/I3ZvpKxQ=="
+    },
     "on-finished": {
       "version": "2.3.0",
       "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@@ -19516,6 +20102,17 @@
       "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=",
       "dev": true
     },
+    "paseto2": {
+      "version": "npm:paseto@2.1.3",
+      "resolved": "https://registry.npmjs.org/paseto/-/paseto-2.1.3.tgz",
+      "integrity": "sha512-BNkbvr0ZFDbh3oV13QzT5jXIu8xpFc9r0o5mvWBhDU1GBkVt1IzHK1N6dcYmN7XImrUmPQ0HCUXmoe2WPo8xsg=="
+    },
+    "paseto3": {
+      "version": "npm:paseto@3.1.0",
+      "resolved": "https://registry.npmjs.org/paseto/-/paseto-3.1.0.tgz",
+      "integrity": "sha512-oVSKoCH89M0WU3I+13NoCP9wGRel0BlQumwxsDZPk1yJtqS76PWKRM7vM9D4bz4PcScT0aIiAipC7lW6hSgkBQ==",
+      "optional": true
+    },
     "passthrough-counter": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/passthrough-counter/-/passthrough-counter-1.0.0.tgz",
@@ -19916,6 +20513,11 @@
       "resolved": "https://npm.lcr.gr/quick-format-unescaped/-/quick-format-unescaped-3.0.3.tgz",
       "integrity": "sha512-dy1yjycmn9blucmJLXOfZDx1ikZJUi6E8bBZLnhPG5gBrVhHXx2xVyqqgKBubVNEXmx51dBACMHpoMQK/N/AXQ=="
     },
+    "quick-lru": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
+      "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA=="
+    },
     "random-bytes": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
@@ -20246,6 +20848,11 @@
         "path-parse": "^1.0.6"
       }
     },
+    "resolve-alpn": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
+      "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="
+    },
     "resolve-cwd": {
       "version": "3.0.0",
       "resolved": "https://npm.lcr.gr/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
diff --git a/package.json b/package.json
index 126ac92971e250bee18721b0ca37c6ebaed234f9..490e5b3129ffcddcb18e624dcac90f162f1e8a7e 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
     "jose": "^3.6.1",
     "koa": "^2.13.0",
     "koa-bodyparser": "^4.3.0",
+    "koa-compose": "^4.1.0",
     "koa-compress": "^5.0.1",
     "koa-csrf": "^3.0.8",
     "koa-etag": "^3.0.0",
@@ -55,6 +56,7 @@
     "node-fetch": "^2.6.1",
     "nodemailer": "^6.4.17",
     "oauth2-server": "^3.1.1",
+    "oidc-provider": "^7.10.1",
     "pg": "^8.3.0",
     "pg-hstore": "^2.3.3",
     "pluralize": "^8.0.0",
diff --git a/src/app.js b/src/app.js
index 72b1476d456a34b0433ff1dfd60239dbea2b691d..98f4a76cd8219f886b8b0c8d86ba8b7525f52a1b 100644
--- a/src/app.js
+++ b/src/app.js
@@ -5,6 +5,9 @@ const bodyparser = require('koa-bodyparser')
 const etag = require('koa-etag')
 const session = require('koa-session')
 const static = require('koa-static')
+const mount = require('koa-mount')
+
+const createOIDCServer = require('domain/auth/oidc/OIDCServer')
 
 const hbs = require('vendor/koa-handlebars')
 const { config } = require('bootstrap')
@@ -54,15 +57,35 @@ module.exports = async function createApp(app = new Koa()) {
 
 	app.use(serviceProvider.attach)
 
-	Object.values(routers).forEach(router => {
+	for (const [key, router] of Object.entries(routers))  {
+		// if (key === 'web') {
+		// 	// const OIDCService = require("./domain/auth/oidc/OIDCService");
+		// 	// const oidcServer = await OIDCService.server
+		// 	// oidcServer.listen(7124)
+		// 	// console.log(oidcServer.urlFor('token'))
+		// 	// router.use(mount('/oidc', oidcServer.app))
+		// 	console.log(router.routes())
+		// 		console.log("FOO BAR BAZ")
+		// 		await ctx.services['auth.oidc'].withProvider(async prov => {
+		// 			prov.app.callback()(ctx.req, ctx.res)
+		// 		})
+		// 	})
+		// }
+
 		debug(
-			'[Prefix "%s"] Mounting %d layers',
+			'[Prefix "%s"] Mounting %d layers for %s',
 			router.opts?.prefix ?? '/',
 			router.stack?.length ?? 0,
+			key,
 		)
+
+
 		app.use(router.routes())
 		app.use(router.allowedMethods())
-	})
+	}
+
+	// const oidcServer = await createOIDCServer()
+	// app.use(oidcServer.app)
 
 	return app
 }
diff --git a/src/config/app.js b/src/config/app.js
index 6cdbb052505bb40da2517c53f3d3513f682dac51..803fe3fbcb17524ee11700faf0c87c3f8ee5de34 100644
--- a/src/config/app.js
+++ b/src/config/app.js
@@ -11,6 +11,7 @@ module.exports = {
 		web: env('WEB_URL', 'http://example.local'),
 	},
 	dev: env('NODE_ENV', 'development') === 'development',
+	safe_mode: env('DISABLE_MUTATION', 'false') === 'true',
 	security: {
 		use_ephemeral: env('USE_EPHEMERAL_KEYS', 'true') === 'true',
 		public_key: null,
diff --git a/src/config/database.js b/src/config/database.js
index c681d35091401844ce5e918c920f875e2933ea09..605593a26bdc524e56eb0618a64691c86907ea58 100644
--- a/src/config/database.js
+++ b/src/config/database.js
@@ -8,6 +8,7 @@ if (useSSL) {
 }
 
 const url = env('DATABASE_URL')
+
 if (url) {
 	const { URL } = require('url')
 	const connectionUrl = new URL(url)
@@ -17,6 +18,16 @@ if (url) {
 		username: connectionUrl.username,
 		password: connectionUrl.password,
 		port: connectionUrl.port,
+		ssl: useSSL,
+		dialectOptions: useSSL ? {
+			ssl: {
+				rejectUnauthorized: false,
+				ca: Buffer.from(
+					env('DATABASE_CA_CERT', null) ?? '',
+					'base64',
+				).toString(),
+			},
+		} : {},
 		log_queries: env('LOG_SQL_QUERIES', 'true') === 'true',
 	}
 } else {
diff --git a/src/core/errors/SafeModeError.js b/src/core/errors/SafeModeError.js
new file mode 100644
index 0000000000000000000000000000000000000000..f07e5c2b83311e45c73d2f8039193cb6b9bffd97
--- /dev/null
+++ b/src/core/errors/SafeModeError.js
@@ -0,0 +1,7 @@
+const HttpError = require('./HttpError')
+
+module.exports = class SafeModeError extends HttpError {
+	constructor() {
+		super(503, `This resource is operating in "safe mode", and is exclusively accepting read-only requests`)
+	}
+}
diff --git a/src/core/injection/ServiceProvider.js b/src/core/injection/ServiceProvider.js
index f0d72e7f749132847c50c76c638ec72ea24f9fec..384ead088b7645eb78b4f779e266060764120ee7 100644
--- a/src/core/injection/ServiceProvider.js
+++ b/src/core/injection/ServiceProvider.js
@@ -52,6 +52,7 @@ module.exports = class ServiceProvider {
 			// require('domain/users/UserService'),
 			require('core/services/dataloaders').DataloaderService,
 			require('domain/auth/AuthenticationService'),
+			require('domain/auth/oidc/OIDCService'),
 			require('domain/users/UserService'),
 			require('domain/data/MetricsService'),
 			// require('domain/eventbrite/EventbriteService'),
diff --git a/src/core/utils/jwt.js b/src/core/utils/jwt.js
index 13832755faa97c5a3afd81e3284765090f4f6199..53bc5074a203c5a86c0633a687ec0935a7f695d4 100644
--- a/src/core/utils/jwt.js
+++ b/src/core/utils/jwt.js
@@ -36,6 +36,27 @@ exports.getKeys = () => {
 	}
 }
 
+exports.getJWKS = async (type = 'pub') => {
+	const { config } = require('bootstrap')
+	const keys = exports.getKeys()
+	const { default: fromKeyLike } = require('jose/jwk/from_key_like')
+
+	const key = keys[type]
+	const jwk = await fromKeyLike(key)
+	const kid = config('app.security.key_id')
+
+	return {
+		keys: [
+			{
+				kid: `${ exports.jwtOptions.keyid_prefix }${ kid }`,
+				use: 'sig',
+				...jwk,
+				alg: 'RS256',
+			},
+		],
+	}
+}
+
 exports.loadKeys = async () => {
 	const { env, config, patchConfig } = require('bootstrap')
 	let [pub, priv] = [
@@ -107,5 +128,5 @@ exports.getClaims = tokenPayload => {
 exports.jwtOptions = {
 	issuer: 'urn:jetsam:systems:auth',
 	claims: 'urn:jetsam:resources:claims',
-	keyid_prefix: 'urn:jetsam:jwk:'
+	keyid_prefix: 'urn:jetsam:resources:jwk:'
 }
diff --git a/src/database/models/OIDCEntity.js b/src/database/models/OIDCEntity.js
new file mode 100644
index 0000000000000000000000000000000000000000..782869eab73caaa120b42085cdadae1bc4ae3203
--- /dev/null
+++ b/src/database/models/OIDCEntity.js
@@ -0,0 +1,86 @@
+const timestamps = require('./properties/timestamps')
+const BaseModel = require('./BaseModel')
+
+class OIDCEntity extends BaseModel {
+	static associate(models) {
+		this.belongsTo(models.User, { foreignKey: 'related_user_id' })
+	}
+
+	toJSON() {
+		return {
+			id: this.id,
+			name: this.name,
+			description: this.description,
+			secret: this.secret,
+			redirect_uris: this.redirect_uris,
+			grant_types: this.grant_types,
+			meta: this.meta,
+			created_at: this.created_at,
+			updated_at: this.updated_at,
+		}
+	}
+}
+
+module.exports = (sequelize, DataTypes) => {
+	OIDCEntity.init(
+		Object.assign(
+			{
+				id: {
+					type: DataTypes.TEXT,
+					primaryKey: true,
+				},
+				related_user_id: {
+					type: DataTypes.UUID,
+					allowNull: true,
+					onDelete: 'CASCADE',
+					onUpdate: 'CASCADE',
+					references: {
+						model: 'users',
+						key: 'id',
+					},
+				},
+				grant_id: {
+					type: DataTypes.TEXT,
+				},
+				user_code: {
+					type: DataTypes.TEXT,
+				},
+				uid: {
+					type: DataTypes.TEXT,
+				},
+				data: {
+					type: DataTypes.JSONB,
+				},
+				expires_at: {
+					type: DataTypes.DATE,
+				},
+				consumed_at: {
+					type: DataTypes.DATE,
+				},
+				meta: {
+					type: DataTypes.JSONB,
+					defaultValue: {},
+				},
+				created_at: {
+					type: DataTypes.DATE,
+					validate: {
+						isDate: true,
+					},
+				},
+				updated_at: {
+					type: DataTypes.DATE,
+					validate: {
+						isDate: true,
+					},
+				},
+			}
+		),
+		{
+			sequelize,
+			paranoid: false,
+			tableName: 'oidc_entities',
+		},
+	)
+
+	return OIDCEntity
+}
diff --git a/src/database/models/User.js b/src/database/models/User.js
index 25566ecf94b4e36a9dd14f99eb1a25503c5e2feb..7aa94e4010e14a00f44c48462010043a869e9d9d 100644
--- a/src/database/models/User.js
+++ b/src/database/models/User.js
@@ -120,15 +120,21 @@ class User extends BaseModel {
 
 	async asJWTToken(extras = {}) {
 		const { sign } = require('core/utils/jwt')
-		const roles = await this.getAuthRoles()
 		return await sign({
+			...(await this.getJWTOptionsClaims()),
+			...extras,
+		})
+	}
+
+	async getJWTOptionsClaims() {
+		const roles = await this.getAuthRoles()
+		return {
 			[jwtOptions.claims]: {
 				'user-id': this.id,
 				'default-role': roles[0],
 				'allowed-roles': roles,
-			},
-			...extras,
-		})
+			}
+		}
 	}
 
 	async getAuthRoles() {
diff --git a/src/domain/auth/OAuthFlow.js b/src/domain/auth/OAuthFlow.js
index 1075abf6c37578d2a9db70008346ba9f671c1a75..ce25c979f4e629ce78b374d5feb6cacd34ac6016 100644
--- a/src/domain/auth/OAuthFlow.js
+++ b/src/domain/auth/OAuthFlow.js
@@ -52,7 +52,18 @@ exports.showOAuthConsent = async (ctx, queryState) => {
 		}
 	}
 
-	const [scopes, isPrivileged] = describeScopeRequest(query.scope)
+	const { client, scopes, isPrivileged } = await validateOAuthState(query)
+
+	return ctx.render('auth/accept-oauth', {
+		user,
+		client: client.toJSON(),
+		scopes,
+		redirect,
+	})
+}
+
+const validateOAuthState = exports.validateOAuthState = async function validateOAuthState(query) {
+	const [scopes, isPrivileged] = exports.describeScopes(query.scope)
 
 	if (isPrivileged) {
 		const allowed = new Set(config('app.security.super_auth_clients'))
@@ -66,13 +77,20 @@ exports.showOAuthConsent = async (ctx, queryState) => {
 		throw new HttpError(400, 'Invalid client id specified')
 	}
 
+	const uri = query.redirect_uri ?? null
+	if (uri == null) {
+		throw new HttpError(400, 'Missing redirect_uri')
+	}
 
-	return ctx.render('auth/accept-oauth', {
-		user,
-		client: client.toJSON(),
+	if (!client.internal && !client.redirect_uris.includes(uri)) {
+		throw new HttpError(400, 'The provided redirect_uri is not authorised for this OAuth client')
+	}
+
+	return {
 		scopes,
-		redirect,
-	})
+		isPrivileged,
+		client,
+	}
 }
 
 async function getOauthQueryFromFlow(flow) {
@@ -123,7 +141,7 @@ exports.handleConsentAcceptance = async (ctx, flow, server) => {
 	ctx.response.status = res.status
 }
 
-const scopeDescriptionMap = {
+const scopeDescriptionMap = exports.validScopes = {
 	'*': {
 		icon: 'admin',
 		name: 'Full Access',
@@ -183,10 +201,9 @@ const scopeDescriptionMap = {
  * @param scope
  * @return {[ScopeDescription[], boolean]}
  */
-function describeScopeRequest(scope = '*') {
+exports.describeScopes = function describeScopeRequest(scope = '*') {
 	let hasAdminRequest = false
 
-	console.log(scope)
 	let scopes = []
 	if (scope.includes(',')) {
 		scopes = scope.split(',')
diff --git a/src/domain/auth/oidc/DBAdapter.js b/src/domain/auth/oidc/DBAdapter.js
new file mode 100644
index 0000000000000000000000000000000000000000..baf2d7088c2491e84eb8cfc36377fad031da8930
--- /dev/null
+++ b/src/domain/auth/oidc/DBAdapter.js
@@ -0,0 +1,93 @@
+const moment = require('moment')
+const { OIDCEntity, User, OAuthClient, AccessToken, RefreshToken, sequelize, Sequelize } = require('database/models')
+const debug = require('debug')('server:auth:oidc:adapter')
+
+const key = (id, name) => `urn:jetsam:resources:oidc:${name}/${id}`
+
+class DBAdapter {
+	constructor(name, { model = OIDCEntity } = {}) {
+		this.model = model
+		this.name = name
+	}
+
+	async upsert(id, data, expiresIn) {
+		// debug("INSERTING", id, data)
+
+		const oidc = await this.model.upsert({
+			id: key(id, this.name),
+			grant_id: data.grantId ?? null,
+			user_code: data.user_code ?? null,
+			uid: data.uid ?? null,
+			data,
+			expires_at: expiresIn ? moment().utc().add(expiresIn, 'seconds') : null,
+		})
+	}
+
+	async find(id) {
+		// debug('find', this.name, id, key(id, this.name))
+
+		if (typeof this[`find${ this.name }`] === 'function') {
+			return await this[`find${ this.name }`](id)
+		}
+
+		const found = await this.model.findByPk(key(id, this.name))
+		if (!found) {
+			return null
+		}
+
+		return {
+			...found.data,
+			...(found.consumed_at ? { consumed: true } : {})
+		}
+	}
+
+	async findClient(id) {
+		const { OAuthClient } = require('database/models')
+		const client = await OAuthClient.findByPk(id)
+		if (client) {
+			return {
+				client_id: client.id,
+				client_secret: client.secret,
+				redirect_uris: client.redirect_uris ?? [],
+				grant_types: [
+					'authorization_code'
+				],
+				response_types: ['code']
+			}
+		}
+
+		return null
+	}
+
+	async findByUserCode(userCode) {
+		const found = await this.model.findOne({ where: { user_code: userCode } });
+		if (!found) return undefined;
+		return {
+			...found.data,
+			...(found.consumed_at ? { consumed: true } : undefined),
+		};
+	}
+
+	async findByUid(uid) {
+		const found = await this.model.findOne({ where: { uid } });
+		if (!found) return undefined;
+		return {
+			...found.data,
+			...(found.consumed_at ? { consumed: true } : undefined),
+		};
+	}
+
+	async destroy(id) {
+		await this.model.destroy({ where: { id: key(id, this.name) } });
+	}
+
+	async consume(id) {
+		await this.model.update({ consumed_at: new Date() }, { where: { id: key(id, this.name) } });
+	}
+
+	async revokeByGrantId(grantId) {
+		await this.model.destroy({ where: { grant_id: grantId } });
+	}
+}
+
+module.exports = DBAdapter
\ No newline at end of file
diff --git a/src/domain/auth/oidc/OIDCServer.js b/src/domain/auth/oidc/OIDCServer.js
new file mode 100644
index 0000000000000000000000000000000000000000..02084f998cc69f503e19f5c8ff29737963224a82
--- /dev/null
+++ b/src/domain/auth/oidc/OIDCServer.js
@@ -0,0 +1,105 @@
+const { Provider } = require('oidc-provider')
+const debug = require('debug')('server:auth:oidc:provider')
+
+module.exports = async function createOIDCServer() {
+	const { config } = require('bootstrap')
+	const DBAdapter = require("./DBAdapter");
+	const { getJWKS } = require('core/utils/jwt')
+	const {validScopes} = require("../OAuthFlow");
+
+	debug(`Creating OIDC Provider with base ${config('app.host.web')}`)
+	const provider = new Provider( /*config('app.host.web') + 'oidc/' */ 'http://trash.4l2.uk/oidc/', {
+		clients: [
+			{
+				client_id: 'kbyuFDidLLm280LIwVFiazOqjO3ty8KH',
+				client_secret: '60Op4HFM0I8ajz0WdiStAbziZ-VFQttXuxixHHs2R7r7-CW8GR79l-mmLqMhc-Sa',
+				redirect_uris: ['https://openidconnect.net/callback'],
+				grant_types: [
+					'authorization_code', 'refresh_token'
+				],
+				response_types: ['code']
+			}
+		],
+		scopes: Object.keys(validScopes),
+		jwks: await getJWKS('priv'),
+		adapter: DBAdapter,
+		cookies: {
+			keys: [config('app.key')],
+			names: {
+				interaction: 'jtoidc.int',
+				resume: 'jtoidc.res',
+				session: 'jtoidc.ses',
+			}
+		},
+		interactions: {
+			url(ctx, interaction) {
+				debug(`OIDC Interaction`, interaction)
+				return `/oidc/i/${ interaction.jti }`
+			}
+		},
+		renderError: async function renderError(ctx, out, error) {
+			console.log("ERRRR", error)
+
+			ctx.body = `<!DOCTYPE html>
+				<head>
+					<title>oops! something went wrong</title>
+					<style>/* css and html classes omitted for brevity, see lib/helpers/defaults.js */</style>
+				</head>
+				<body>
+					<div>
+					<h1>oops! something went wrong</h1>
+					${Object.entries(out).map(([key, value]) => `<pre><strong>${key}</strong>: ${value}</pre>`).join('')}
+					</div>
+				</body>
+			</html>`
+
+			ctx.type = 'text/html'
+		},
+		features: {
+			devInteractions: {
+				enabled: false,
+			},
+		},
+		pkce: {
+			required: () => false,
+		},
+		routes: {
+			authorization: '/oidc/auth',
+			backchannel_authentication: '/oidc/backchannel',
+			code_verification: '/oidc/device',
+			device_authorization: '/oidc/device/oidc/auth',
+			end_session: '/oidc/session/oidc/end',
+			introspection: '/oidc/token/oidc/introspection',
+			jwks: '/.well-known/jwks.json',
+			pushed_authorization_request: '/oidc/request',
+			registration: '/oidc/reg',
+			revocation: '/oidc/token/oidc/revocation',
+			token: '/oidc/token',
+			userinfo: '/oidc/me'
+		},
+		async findAccount(ctx, id) {
+			debug("find account", id)
+			return {
+				accountId: id,
+				async claims(use, scope) {
+					const { User } = require('database/models')
+					const user = await User.findByPk(id)
+					debug(`Finding account for OIDC ${id}, ${use}, ${scope}`)
+					const details = await user.getJWTOptionsClaims()
+
+					console.log(details)
+
+					return {
+						sub: user.id,
+						id: user.id,
+						email: user.email,
+						scope,
+						...details
+					}
+				}
+			}
+		}
+	})
+
+	return provider
+}
\ No newline at end of file
diff --git a/src/domain/auth/oidc/OIDCService.js b/src/domain/auth/oidc/OIDCService.js
new file mode 100644
index 0000000000000000000000000000000000000000..eb26d6373de656175482fcac4bd849bae2ac7532
--- /dev/null
+++ b/src/domain/auth/oidc/OIDCService.js
@@ -0,0 +1,25 @@
+const ContextualModule = require('core/injection/ContextualModule')
+const createOIDCServer = require('domain/auth/oidc/OIDCServer')
+
+module.exports = class OIDCService extends ContextualModule {
+	static getServiceName() {
+		return 'auth.oidc'
+	}
+
+	static get profileMethods() {
+		return ['withProvider', 'getInteraction']
+	}
+
+	async init() {
+		this.provider = await provider
+	}
+
+	async withProvider(fn) {
+		return await fn(this.provider)
+	}
+
+	async getInteraction() {
+		 return await this.provider.interactionDetails(this.ctx.req, this.ctx.res)
+	}
+}
+let provider = module.exports.server = createOIDCServer()
\ No newline at end of file
diff --git a/src/http/controllers/auth.js b/src/http/controllers/auth.js
index 1284e3b269e41f6d0745c92c0a958b705642467f..fe8927530bc5916e61aaf0f5baea715cc1c9afbe 100644
--- a/src/http/controllers/auth.js
+++ b/src/http/controllers/auth.js
@@ -24,6 +24,7 @@ exports.showLogin = async ctx => {
 	}
 
 	data.state_string = `?${ state.toString() }`
+	data.login_action = `/login${ data.state_string}`
 	return ctx.render('auth/login', data)
 }
 
diff --git a/src/http/controllers/oidc.js b/src/http/controllers/oidc.js
new file mode 100644
index 0000000000000000000000000000000000000000..d8d6a9794ab8c971b82d6e635a6002b6d18bd2bf
--- /dev/null
+++ b/src/http/controllers/oidc.js
@@ -0,0 +1,114 @@
+const compose = require('koa-compose')
+const HttpError = require("../../core/errors/HttpError");
+const {describeScopes, validateOAuthState} = require("../../domain/auth/OAuthFlow");
+const debug = require('debug')('server:routes:oidc')
+
+exports.mapRoutes = async ctx => {
+	await ctx.services['auth.oidc'].withProvider(async prov => {
+		const middleware = prov.app.middleware
+		const fn = compose(middleware)
+		await prov.app.handleRequest(ctx, fn)
+	})
+}
+
+exports.handleLogin = async ctx => {
+	const interaction = await ctx.services['auth.oidc'].getInteraction()
+	const { uid, prompt, params, session } = interaction
+
+	if (prompt.name !== 'login') {
+		throw new HttpError(400, 'Invalid interaction prompt')
+	}
+
+	const { email, password } = ctx.request.body
+	const user = await ctx.services['core.auth'].attemptLogin(email, password)
+
+	const result = {
+		login: {
+			accountId: user.id,
+		},
+	}
+
+	return await ctx.services['auth.oidc'].withProvider(p => p.interactionFinished(ctx.req, ctx.res, result, {
+		mergeWithLastSubmission: false,
+	}))
+}
+
+exports.interaction = async ctx => {
+	const interaction = await ctx.services['auth.oidc'].getInteraction()
+	const { uid, prompt, params, session } = interaction
+
+	debug("Interaction handler", interaction)
+
+	switch (prompt.name) {
+		case 'login':
+			return await ctx.render('auth/login', {
+				login_action: `/oidc/i/${uid}/login`
+			})
+		case 'consent':
+			const { client, scopes } = await validateOAuthState(params)
+			return await ctx.render('auth/accept-oauth', {
+				client: client.toJSON(),
+				scopes,
+				confirm_action: `/oidc/i/${ uid }/confirm`,
+				reject_action: `/oidc/i/${ uid }/reject`,
+			})
+	}
+}
+
+exports.confirm = async ctx => {
+	const interaction = await ctx.services['auth.oidc'].getInteraction()
+	const { uid, prompt, params, session, ...details } = interaction
+
+	if (prompt.name !== 'consent') {
+		throw new HttpError(400, 'Invalid interaction prompt')
+	}
+
+	let grantId = details.grantId
+	const grant = await ctx.services['auth.oidc'].withProvider(provider => {
+		if (grantId) {
+			return provider.Grant.find(grantId);
+		} else {
+			return new provider.Grant({
+				accountId: session.accountId,
+				clientId: params.client_id,
+				jti: details.jti ?? undefined,
+			});
+		}
+	})
+
+	if (prompt.details.missingOIDCScope) {
+		grant.addOIDCScope(prompt.details.missingOIDCScope.join(' '));
+	}
+	if (prompt.details.missingOIDCClaims) {
+		grant.addOIDCClaims(prompt.details.missingOIDCClaims);
+	}
+	if (prompt.details.missingResourceScopes) {
+		// eslint-disable-next-line no-restricted-syntax
+		for (const [indicator, scope] of Object.entries(prompt.details.missingResourceScopes)) {
+			grant.addResourceScope(indicator, scope.join(' '));
+		}
+	}
+
+	grantId = await grant.save()
+
+	const consent = {}
+	if (!details.grantId) {
+		consent.grantId = grantId
+	}
+	const result = { consent }
+
+	return await ctx.services['auth.oidc'].withProvider(p => p.interactionFinished(ctx.req, ctx.res, result, {
+		mergeWithLastSubmission: true,
+	}))
+}
+
+exports.reject = async ctx => {
+	const result = {
+		error: 'access_denied',
+		error_description: 'The request for access was rejected',
+	}
+
+	return await ctx.services['auth.oidc'].withProvider(p => p.interactionFinished(ctx.req, ctx.res, result, {
+		mergeWithLastSubmission: false,
+	}))
+}
\ No newline at end of file
diff --git a/src/http/middleware/ErrorHandler.js b/src/http/middleware/ErrorHandler.js
index 86b9e163a2771d163dc5ab536d81821e3875ce5b..29985984bc80c85b4a333e9c38f1e3c69e8d3cea 100644
--- a/src/http/middleware/ErrorHandler.js
+++ b/src/http/middleware/ErrorHandler.js
@@ -1,4 +1,5 @@
 const HttpError = require('core/errors/HttpError')
+const SafeModeError = require('core/errors/SafeModeError')
 const SentryReporter = require('./SentryReporter')
 
 module.exports = async (ctx, next) => {
@@ -7,7 +8,11 @@ module.exports = async (ctx, next) => {
 	try {
 		await next(ctx)
 	} catch (e) {
-		await SentryReporter.report(e, ctx)
+		if (e instanceof SafeModeError) {
+			console.error(e)
+		} else {
+			await SentryReporter.report(e, ctx)
+		}
 		hasHandledError = true
 
 		if (e instanceof HttpError) {
diff --git a/src/http/middleware/SafeModeBlock.js b/src/http/middleware/SafeModeBlock.js
new file mode 100644
index 0000000000000000000000000000000000000000..f90ecd9ac1807b811ae82bfeb226ec575db115f2
--- /dev/null
+++ b/src/http/middleware/SafeModeBlock.js
@@ -0,0 +1,10 @@
+const SafeModeError = require('core/errors/SafeModeError')
+
+module.exports = async (ctx, next) => {
+	const { config } = require('bootstrap')
+	const safeMode = config('app.safe_mode')
+	if (safeMode) {
+		throw new SafeModeError()
+	}
+	return await next()
+}
diff --git a/src/http/routers/routes_v2.js b/src/http/routers/routes_v2.js
index 8f9ea777a90c90e011b6e8be169892d069615d0f..45691f1663a664f4481155ab33d3614e2eec492e 100644
--- a/src/http/routers/routes_v2.js
+++ b/src/http/routers/routes_v2.js
@@ -5,6 +5,7 @@ const router = new Router({ prefix: '/v2' })
 const controller = (path, handler) => require(`../controllers/${path}`)[handler]
 const param = name => require(`../params/${name}`)
 const { env, config } = require('bootstrap')
+const safemode = require('http/middleware/SafeModeBlock')
 
 apiMiddlewareGroup.forEach(middleware => router.use(middleware))
 
@@ -25,27 +26,28 @@ router.get(
 		}),
 )
 
-router.post('/auth/login', controller('api/auth', 'login'))
-router.post('/auth/register', controller('api/auth', 'register'))
+router.post('/auth/login', safemode, controller('api/auth', 'login'))
+router.post('/auth/register', safemode, controller('api/auth', 'register'))
 router.post(
 	'/auth/password-reset',
+	safemode,
 	controller('api/auth', 'triggerPasswordReset'),
 )
 
 router.get('/self', controller('api/user', 'self'))
 router.get('/self/surveys', controller('api/v2/surveys', 'joined'))
 router.get('/self/bundles', controller('api/app', 'getBundles'))
-router.put('/self/:property', controller('api/user', 'updateOne'))
+router.put('/self/:property', safemode, controller('api/user', 'updateOne'))
 
 router.get('/metrics', controller('api/content', 'getWithin'))
-router.post('/metrics', controller('api/content', 'postMetric'))
+router.post('/metrics', safemode, controller('api/content', 'postMetric'))
 
 router.get('/images', noop)
 router.post('/images', noop)
 router.post('/images/:imageId/share', noop)
 
 router.get('/uploads', noop)
-router.post('/uploads', controller('api/v2/uploads', 'createUpload'))
+router.post('/uploads', safemode, controller('api/v2/uploads', 'createUpload'))
 router.get('/uploads/:upload_id', noop)
 router.delete('/uploads/:upload_id', noop)
 router.put('/uploads/:upload_id/:property', noop)
@@ -57,12 +59,12 @@ router.get('/surveys', controller('api/v2/surveys', 'list'))
 router.get('/excerpts', controller('api/v2/surveys', 'listExcerpts'))
 router.get('/excerpts/:excerpt', controller('api/v2/surveys', 'getExcerpt'))
 router.get('/surveys/:survey', controller('api/v2/surveys', 'get'))
-router.post('/surveys/:survey/membership', controller('api/v2/surveys', 'join'))
-router.delete('/surveys/:survey/membership', controller('api/v2/surveys', 'leave'))
+router.post('/surveys/:survey/membership', safemode, controller('api/v2/surveys', 'join'))
+router.delete('/surveys/:survey/membership', safemode, controller('api/v2/surveys', 'leave'))
 if (config('app.dev')) {
-	router.post('/surveys/factory', controller('api/v2/factories', 'survey'))
+	router.post('/surveys/factory', safemode, controller('api/v2/factories', 'survey'))
 }
 
-router.post('/an/ev', controller('api/analytics', 'track'))
+router.post('/an/ev', safemode, controller('api/analytics', 'track'))
 
 module.exports = router
diff --git a/src/http/routes.js b/src/http/routes.js
index f268250e40587a65367a59ed96462775360e14dd..c948e3ba9ec693d6a0d075dfffce30e7fecc657b 100644
--- a/src/http/routes.js
+++ b/src/http/routes.js
@@ -17,37 +17,42 @@ const loaders = require('http/middleware/MountLoaders')
 const userGate = require('http/middleware/RequiresAuth')
 const authRedirect = require('http/middleware/RedirectToLogin')
 const device = require('http/middleware/DeviceProperties').extractDevice
+const safemode = require('http/middleware/SafeModeBlock')
+
+const createOIDCServer = require('domain/auth/oidc/OIDCServer')
 
 const v2 = require('./routers/routes_v2')
 
 const well_known = new Router({ prefix: '/.well-known' })
 well_known.get('wk.jwks', '/jwks.json', async ctx => {
-	const { getKeys } = require('core/utils/jwt')
-	const { pub } = getKeys()
-	const { default: fromKeyLike } = require('jose/jwk/from_key_like')
-
-	const jwk = await fromKeyLike(pub)
-
+	const { getJWKS } = require('core/utils/jwt')
 	ctx.set('Cache-Control', `public, max-age=30`)
 
-	ctx.body = {
-		keys: [
-			{
-				use: 'sig',
-				...jwk,
-				alg: 'RS256',
-			},
-		],
-	}
+	ctx.body = await getJWKS()
 })
+well_known.get('wk.oidc', '/openid-configuration', controller('oidc', 'mapRoutes'))
 
 const web = new Router()
 web.use(profiling)
 web.use(device)
 
+web.all('/test/oidc', ctx => {
+	ctx.body = {
+		body: ctx.request.body,
+		query: ctx.request.query,
+		headers: ctx.request.headers,
+	}
+})
+
 web.use(well_known.allowedMethods())
 web.use(well_known.routes())
 
+web.all('/oidc/i/:uid', controller('oidc', 'interaction'))
+web.all('/oidc/i/:uid/login', controller('oidc', 'handleLogin'))
+web.all('/oidc/i/:uid/confirm', controller('oidc', 'confirm'))
+web.all('/oidc/i/:uid/reject', controller('oidc', 'reject'))
+web.all(/^\/oidc\/.*/, controller('oidc', 'mapRoutes'))
+
 web.get('/login', controller('auth', 'showLogin'))
 web.post('/login', controller('auth', 'login'))
 web.get('/logout', controller('auth', 'logout'))
@@ -99,61 +104,67 @@ function mount(api) {
 		}
 	})
 
-	api.post('/metrics', controller('api/content', 'postMetric'))
+	api.post('/metrics', safemode, controller('api/content', 'postMetric'))
 	api.get('/metrics', controller('api/content', 'getWithin'))
 
 	api.get('/images', controller('api/storage', 'getFiles'))
 	api.post(
 		'/images',
+		safemode,
 		upload.single('featured_image'),
 		controller('api/storage', 'saveFile'),
 	)
 	api.post(
 		'/images/:imageId/feature',
+		safemode,
 		controller('api/storage', 'featureImage'),
 	)
 
 	/** @deprecated */
 	api.post(
 		'/feature',
+		safemode,
 		upload.single('featured_image'),
 		controller('api/storage', 'saveFile'),
 	)
 
 	api.get('/feed', controller('api/storage', 'feed'))
-	api.post('/feed/:fileId/like', controller('api/storage', 'like'))
-	api.post('/feed/:fileId/unlike', controller('api/storage', 'unlike'))
+	api.post('/feed/:fileId/like', safemode, controller('api/storage', 'like'))
+	api.post('/feed/:fileId/unlike', safemode, controller('api/storage', 'unlike'))
 
-	api.post('/register', controller('api/auth', 'register'))
+	api.post('/register', safemode, controller('api/auth', 'register'))
 	api.post('/login', controller('api/auth', 'login'))
 
-	api.post('/auth/reset-token', controller('api/auth', 'triggerPasswordReset'))
+	api.post('/auth/reset-token', safemode, controller('api/auth', 'triggerPasswordReset'))
 	api.post(
 		'/auth/reset-password',
+		safemode,
 		controller('api/auth', 'handlePasswordReset'),
 	)
 
 	api.param('oauthClientId', param('oauth_client'))
 
 	api.get('/oauth/clients', controller('api/oauth', 'listClients'))
-	api.post('/oauth/clients', controller('api/oauth', 'createClient'))
+	api.post('/oauth/clients', safemode, controller('api/oauth', 'createClient'))
 	api.post(
 		'/oauth/clients/:oauthClientId/redirects',
+		safemode,
 		controller('api/oauth', 'addClientRedirect'),
 	)
 	api.delete(
 		'/oauth/clients/:oauthClientId/redirects',
+		safemode,
 		controller('api/oauth', 'removeClientRedirect'),
 	)
 
 	api.get('/self', controller('api/user', 'self'))
 	api.get('/self/bundles', controller('api/app', 'getBundles'))
-	api.put('/self/:property', controller('api/user', 'updateOne'))
+	api.put('/self/:property', safemode, controller('api/user', 'updateOne'))
 
 	api.post('/an/id', async ctx => {})
-	api.post('/an/ev', controller('api/analytics', 'track'))
+	api.post('/an/ev', safemode, controller('api/analytics', 'track'))
 
-	api.post('/feedback', controller('api/feedback', 'send'))
+	api.post('/feedback', safemode, controller('api/feedback', 'send'))
 
 	api.use(v2.allowedMethods())
 	api.use(v2.routes())
diff --git a/views/auth/accept-oauth.hbs b/views/auth/accept-oauth.hbs
index 7b1433a8d91cb71cc65b70ae4ec36144187c2563..4de72588e7cc77d22448d3f51fec2f0f4d24aceb 100644
--- a/views/auth/accept-oauth.hbs
+++ b/views/auth/accept-oauth.hbs
@@ -36,14 +36,26 @@
 			{{/each}}
 		</ul>
 		<div class="row space-4 center-content">
-			<form method="post"
-				  action="/auth/authorize?action=accept&auth_state={{redirect}}">
+			<form
+				method="post"
+				{{#if confirm_action}}
+				action="{{confirm_action}}"
+				{{else}}
+				action="/auth/authorize?action=accept&auth_state={{redirect}}"
+				{{/if}}
+			>
 			<button type="submit"
 					class="button success">Accept
 			</button>
 			</form>
-			<form method="post"
-				  action="/auth/authorize?action=deny&auth_state={{redirect}}">
+			<form
+				method="post"
+				{{#if reject_action}}
+				action="{{reject_action}}"
+				{{else}}
+				action="/auth/authorize?action=deny&auth_state={{redirect}}"
+				{{/if}}
+			>
 			<button type="submit" class="button danger">
 				Reject
 			</button>
diff --git a/views/auth/login.hbs b/views/auth/login.hbs
index e2d1145923d3fe7ea8eee25470e84ac2601926be..3cd34540583ccbda20f70f9c1b39e391ac760aca 100644
--- a/views/auth/login.hbs
+++ b/views/auth/login.hbs
@@ -34,22 +34,26 @@
 			</p>
 		</div>
 
-			<form class="form column space-1" method="post" action="/login{{#if state_string}}{{state_string}}{{/if}}">
-		<div class="control column">
-			<label for="email">Email Address</label>
-			<input id="email" name="email" type="email" placeholder="Email Address"/>
-		</div>
-		<div class="control column">
-			<label for="password">Password</label>
-			<input id="password" name="password" type="password" placeholder="Password"/>
-		</div>
+		{{#if login_action}}
+		<form class="form column space-1" method="post" action="{{login_action}}">
+		{{else}}
+		<form class="form column space-1" method="post" action="/login{{#if state_string}}{{state_string}}{{/if}}">
+		{{/if}}
+			<div class="control column">
+				<label for="email">Email Address</label>
+				<input id="email" name="email" type="email" placeholder="Email Address"/>
+			</div>
+			<div class="control column">
+				<label for="password">Password</label>
+				<input id="password" name="password" type="password" placeholder="Password"/>
+			</div>
 
-		<button type="submit" class="button primary">
-			Log In
-		</button>
-		<div class="row center-content">
-			<a href="/reset-password">Forgotten Password?</a>
-		</div>
+			<button type="submit" class="button primary">
+				Log In
+			</button>
+			<div class="row center-content">
+				<a href="/reset-password">Forgotten Password?</a>
+			</div>
 		</form>
 	</div>
 	<div class="split-child bg-jetsam center-content column space-2">