initial commit
This commit is contained in:
commit
b4f2bdba4a
|
@ -0,0 +1,12 @@
|
|||
**/node_modules
|
||||
build
|
||||
data
|
||||
**/dist
|
||||
packages/build
|
||||
Dockerfile
|
||||
secrets
|
||||
|
||||
# dotfiles
|
||||
.git*
|
||||
.vscode
|
||||
**/.env*
|
|
@ -0,0 +1,27 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/data
|
||||
/secrets
|
|
@ -0,0 +1,81 @@
|
|||
FROM node:20-alpine AS base
|
||||
|
||||
FROM base as dev_dep
|
||||
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app
|
||||
WORKDIR /home/node/app
|
||||
|
||||
# --- dependencies ---
|
||||
COPY --chown=node:node package*.json ./
|
||||
COPY --chown=node:node backend/package*.json ./backend/
|
||||
COPY --chown=node:node frontend/package*.json ./frontend/
|
||||
|
||||
USER node
|
||||
RUN npm install --include=dev
|
||||
|
||||
FROM base as dep
|
||||
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app
|
||||
WORKDIR /home/node/app
|
||||
|
||||
# --- dependencies ---
|
||||
COPY --chown=node:node package*.json ./
|
||||
COPY --chown=node:node backend/package*.json ./backend/
|
||||
COPY --chown=node:node frontend/package*.json ./frontend/
|
||||
|
||||
USER node
|
||||
RUN npm install --omit=dev
|
||||
|
||||
#
|
||||
# === BUILDER ===
|
||||
#
|
||||
|
||||
FROM base as build
|
||||
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app
|
||||
WORKDIR /home/node/app
|
||||
|
||||
COPY --from=dev_dep /home/node/app/ ./
|
||||
|
||||
COPY --chown=node:node . .
|
||||
|
||||
# --- build frontend ---
|
||||
|
||||
RUN npm -w frontend run build
|
||||
|
||||
# --- build backend ---
|
||||
|
||||
RUN npx -w backend prisma generate
|
||||
RUN npm -w backend run build
|
||||
|
||||
#
|
||||
# === RUNNER ===
|
||||
#
|
||||
|
||||
FROM base as run
|
||||
WORKDIR /home/node/app
|
||||
COPY --from=dep /home/node/app/ ./
|
||||
COPY package*.json docker-start.sh ./
|
||||
|
||||
# --- prepare frontend ---
|
||||
|
||||
RUN mkdir -p frontend
|
||||
COPY --from=build /home/node/app/frontend/dist ./frontend/
|
||||
|
||||
# --- prepare server ---
|
||||
|
||||
RUN mkdir -p backend
|
||||
COPY --from=build /home/node/app/backend/package.json ./backend/
|
||||
COPY --from=build /home/node/app/backend/prisma ./backend/prisma
|
||||
COPY --from=build /home/node/app/backend/dist ./backend/dist
|
||||
|
||||
# --- finalize ---
|
||||
|
||||
RUN npx -w backend prisma generate
|
||||
|
||||
# set runtime env variables
|
||||
|
||||
ENV PORT 3000
|
||||
ENV NODE_ENV production
|
||||
ENV SERVE_FRONTEND /home/node/app/frontend
|
||||
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT [ "/bin/sh" ]
|
||||
CMD [ "./docker-start.sh" ]
|
|
@ -0,0 +1,5 @@
|
|||
# fediverse-auth
|
||||
|
||||
Providing a central OpenID Connect service for Fediverse identification
|
||||
|
||||
Leverages OpenID Connect Auto Discovery & Dynamic Client Registration
|
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
dist
|
|
@ -0,0 +1,7 @@
|
|||
# fediverse-auth Backend
|
||||
|
||||
Built with Prisma, TypeScript and oidc-provider
|
||||
|
||||
## Development
|
||||
|
||||
Move `example.env` to `.env` and edit to your desires
|
|
@ -0,0 +1,23 @@
|
|||
# REQUIRED: set the database URL
|
||||
DATABASE_URL="postgres://postgres@127.0.0.1:5432/fediauth"
|
||||
|
||||
NODE_ENV=development
|
||||
SESSION_SECRET=abc123
|
||||
PORT=3000
|
||||
|
||||
OIDC_ISSUER=http://localhost:3000
|
||||
|
||||
# redirect routes to this host
|
||||
# used by some alternate packager, like vite
|
||||
CLIENT_HOST=http://localhost:5173
|
||||
|
||||
# Lemmy Polyfill
|
||||
LEMMY_HOST=
|
||||
LEMMY_USER=
|
||||
LEMMY_PASS=
|
||||
LEMMY_TOKEN=
|
||||
|
||||
# Mastodon & Recieve
|
||||
MASTODON_HOST=
|
||||
MASTODON_USER=
|
||||
MASTODON_TOKEN=
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"name": "@fediverse-auth/backend",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.13.0",
|
||||
"@tsconfig/recommended": "^1.0.6",
|
||||
"body-parser": "^1.20.2",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"express-session": "^1.18.0",
|
||||
"oidc-provider": "^8.4.6",
|
||||
"openid-client": "^5.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-session": "^1.18.0",
|
||||
"@types/node": "^20.12.10",
|
||||
"@types/oidc-provider": "^8.4.4",
|
||||
"dotenv": "^16.4.5",
|
||||
"prisma": "^5.13.0",
|
||||
"tsx": "^4.9.3",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx watch -r dotenv/config src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "oidc_model" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" INTEGER NOT NULL,
|
||||
"payload" JSONB NOT NULL,
|
||||
"grantId" TEXT,
|
||||
"userCode" TEXT,
|
||||
"uid" TEXT,
|
||||
"expiresAt" TIMESTAMP(3),
|
||||
"consumedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "oidc_model_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "oidc_model_uid_key" ON "oidc_model"("uid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "oidc_model_id_type_key" ON "oidc_model"("id", "type");
|
|
@ -0,0 +1,9 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "AuthSession" (
|
||||
"id" TEXT NOT NULL,
|
||||
"one_time_code" TEXT NOT NULL,
|
||||
"mode" TEXT NOT NULL,
|
||||
"user_sub" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "AuthSession_pkey" PRIMARY KEY ("id")
|
||||
);
|
|
@ -0,0 +1,3 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
|
@ -0,0 +1,34 @@
|
|||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model OidcModel {
|
||||
id String @id
|
||||
type Int // name of the oidc-provider model
|
||||
payload Json
|
||||
grantId String?
|
||||
userCode String?
|
||||
uid String? @unique
|
||||
expiresAt DateTime?
|
||||
consumedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([id, type])
|
||||
@@map("oidc_model")
|
||||
}
|
||||
|
||||
model AuthSession {
|
||||
id String @id @default(uuid())
|
||||
one_time_code String
|
||||
mode String // RECV_CODE | SEND_CODE -- is the service receiving or sending
|
||||
user_sub String
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
import { setupAllProviders } from "./lib/delivery/index.js";
|
||||
import { app as Express } from "./lib/express.js";
|
||||
|
||||
if (typeof process.env.SESSION_SECRET !== "string") {
|
||||
throw new Error("SESSION_SECRET is not defined");
|
||||
}
|
||||
|
||||
if (typeof process.env.NODE_ENV !== "string") {
|
||||
process.env.NODE_ENV = "development";
|
||||
}
|
||||
|
||||
if (typeof process.env.OIDC_ISSUER !== "string") {
|
||||
throw new Error("OIDC_ISSUER is not defined");
|
||||
}
|
||||
|
||||
if (typeof process.env.OIDC_REGISTRATION_TOKEN !== "string") {
|
||||
// throw new Error("OIDC_REGISTRATION_TOKEN is not defined");
|
||||
console.warn(
|
||||
"OIDC_REGISTRATION_TOKEN is not defined\nThis removes the requirement for passing a token and anyone can then register a client"
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
if (typeof process.env.SESSION_SECRET !== "string") {
|
||||
throw new Error("SESSION_SECRET is not defined");
|
||||
}
|
||||
|
||||
if (typeof process.env.OIDC_JWK_KEYS_FILE !== "string") {
|
||||
throw new Error("OIDC_JWK_KEYS_FILE is not defined");
|
||||
}
|
||||
|
||||
if (typeof process.env.OIDC_COOKIE_KEYS_FILE !== "string") {
|
||||
throw new Error("OIDC_COOKIE_KEYS_FILE is not defined");
|
||||
}
|
||||
}
|
||||
|
||||
setupAllProviders().then((providers) => {
|
||||
let errors: string[] = [];
|
||||
|
||||
for (const prov of providers) {
|
||||
if (prov.status === "rejected") {
|
||||
errors.push(prov.reason || "unknown");
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
console.log("setup all deliver providers");
|
||||
} else {
|
||||
console.error("provider setup failed", errors);
|
||||
}
|
||||
});
|
||||
|
||||
Express.listen(process.env.PORT, () => {
|
||||
console.log("Listening on :" + process.env.PORT);
|
||||
});
|
|
@ -0,0 +1,158 @@
|
|||
import { PrismaClient, OidcModel, Prisma } from "@prisma/client";
|
||||
import { Adapter, AdapterPayload } from "oidc-provider";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const types = [
|
||||
"Session",
|
||||
"AccessToken",
|
||||
"AuthorizationCode",
|
||||
"RefreshToken",
|
||||
"DeviceCode",
|
||||
"ClientCredentials",
|
||||
"Client",
|
||||
"InitialAccessToken",
|
||||
"RegistrationAccessToken",
|
||||
"Interaction",
|
||||
"ReplayDetection",
|
||||
"PushedAuthorizationRequest",
|
||||
"Grant",
|
||||
"BackchannelAuthenticationRequest",
|
||||
].reduce(
|
||||
(map, name, i) => ({ ...map, [name]: i + 1 }),
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
const prepare = (doc: OidcModel) => {
|
||||
const isPayloadJson =
|
||||
doc.payload &&
|
||||
typeof doc.payload === "object" &&
|
||||
!Array.isArray(doc.payload);
|
||||
|
||||
const payload = isPayloadJson ? (doc.payload as Prisma.JsonObject) : {};
|
||||
|
||||
return {
|
||||
...payload,
|
||||
...(doc.consumedAt ? { consumed: true } : undefined),
|
||||
};
|
||||
};
|
||||
|
||||
const expiresAt = (expiresIn?: number) =>
|
||||
expiresIn ? new Date(Date.now() + expiresIn * 1000) : null;
|
||||
|
||||
export class PrismaAdapter implements Adapter {
|
||||
type: number;
|
||||
|
||||
constructor(name: string) {
|
||||
this.type = types[name];
|
||||
}
|
||||
|
||||
async upsert(
|
||||
id: string,
|
||||
payload: AdapterPayload,
|
||||
expiresIn?: number
|
||||
): Promise<void> {
|
||||
const data = {
|
||||
type: this.type,
|
||||
payload: payload as Prisma.JsonObject,
|
||||
grantId: payload.grantId,
|
||||
userCode: payload.userCode,
|
||||
uid: payload.uid,
|
||||
expiresAt: expiresAt(expiresIn),
|
||||
};
|
||||
|
||||
await prisma.oidcModel.upsert({
|
||||
where: {
|
||||
id_type: {
|
||||
id,
|
||||
type: this.type,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
...data,
|
||||
},
|
||||
create: {
|
||||
id,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async find(id: string): Promise<AdapterPayload | undefined> {
|
||||
const doc = await prisma.oidcModel.findUnique({
|
||||
where: {
|
||||
id_type: {
|
||||
id,
|
||||
type: this.type,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!doc || (doc.expiresAt && doc.expiresAt < new Date())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return prepare(doc);
|
||||
}
|
||||
|
||||
async findByUserCode(userCode: string): Promise<AdapterPayload | undefined> {
|
||||
const doc = await prisma.oidcModel.findFirst({
|
||||
where: {
|
||||
userCode,
|
||||
},
|
||||
});
|
||||
|
||||
if (!doc || (doc.expiresAt && doc.expiresAt < new Date())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return prepare(doc);
|
||||
}
|
||||
|
||||
async findByUid(uid: string): Promise<AdapterPayload | undefined> {
|
||||
const doc = await prisma.oidcModel.findUnique({
|
||||
where: {
|
||||
uid,
|
||||
},
|
||||
});
|
||||
|
||||
if (!doc || (doc.expiresAt && doc.expiresAt < new Date())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return prepare(doc);
|
||||
}
|
||||
|
||||
async consume(id: string): Promise<void> {
|
||||
await prisma.oidcModel.update({
|
||||
where: {
|
||||
id_type: {
|
||||
id,
|
||||
type: this.type,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
consumedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async destroy(id: string): Promise<void> {
|
||||
await prisma.oidcModel.delete({
|
||||
where: {
|
||||
id_type: {
|
||||
id,
|
||||
type: this.type,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async revokeByGrantId(grantId: string): Promise<void> {
|
||||
await prisma.oidcModel.deleteMany({
|
||||
where: {
|
||||
grantId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,383 @@
|
|||
import express from "express";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { doesInstanceSupportOIDC, oidc } from "./oidc.js";
|
||||
import {
|
||||
DOMAIN_REGEX,
|
||||
isInstanceDomainValid,
|
||||
makeClientPublic,
|
||||
} from "./utils.js";
|
||||
import { getNodeInfo } from "./nodeinfo.js";
|
||||
import { getProviderFor } from "./delivery/index.js";
|
||||
import { prisma } from "./prisma.js";
|
||||
import { ReceiveCodeProvider } from "./delivery/receive.js";
|
||||
|
||||
const app = express.Router();
|
||||
|
||||
app.use(cookieParser());
|
||||
|
||||
/****
|
||||
* Fedi auth & account verification
|
||||
****/
|
||||
|
||||
/**
|
||||
* Get the current user's session if it exists
|
||||
*/
|
||||
app.get("/whoami", async (req, res) => {
|
||||
const session = await oidc.Session.find(req.cookies._session);
|
||||
if (!session) return res.json({ success: false, error: "no-session" });
|
||||
|
||||
res.json({
|
||||
sub: session.accountId,
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
# login steps #
|
||||
- each login step saves progress in the session
|
||||
- *similar* to matrix's auth
|
||||
- when a step returns {success:true}, that step is completed and the next step is sent back to the client
|
||||
*/
|
||||
|
||||
/**
|
||||
* Step #1: Verify instance
|
||||
* Check if an instance is supported
|
||||
*
|
||||
* TODO: cache this response (or parts of it)
|
||||
*/
|
||||
app.post("/login/step/instance", async (req, res) => {
|
||||
let domain: string;
|
||||
|
||||
if (typeof req.body.domain !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Domain is not a string",
|
||||
});
|
||||
}
|
||||
|
||||
domain = req.body.domain;
|
||||
|
||||
if (!new RegExp(DOMAIN_REGEX).test(domain) || domain.indexOf(".") === -1) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `The domain proided (${domain}) is not a valid domain`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await isInstanceDomainValid(domain))) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error:
|
||||
"The instance provided either doesn't have a valid NodeInfo or doesn't support ActivityPub",
|
||||
});
|
||||
}
|
||||
|
||||
const nodeinfo = await getNodeInfo(domain);
|
||||
const deliveryProvider = getProviderFor(nodeinfo.software.name);
|
||||
|
||||
req.session.login = {
|
||||
prompt: "USERNAME", // change this if oidc is available
|
||||
instance: domain,
|
||||
method: deliveryProvider ? "SEND_CODE" : "RECV_CODE",
|
||||
};
|
||||
|
||||
// const oidcSupport = await doesInstanceSupportOIDC(domain);
|
||||
|
||||
// TODO: detect next step for the client (oidc, receive code, send code)
|
||||
|
||||
req.session.save(() => {
|
||||
res.send({ success: true, step: "USERNAME" });
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/login/step/username", async (req, res) => {
|
||||
if (!req.session?.login || req.session.login.prompt !== "USERNAME") {
|
||||
return res.status(400).json({ success: false, error: "wrong_step" });
|
||||
}
|
||||
|
||||
const { method, instance } = req.session.login;
|
||||
const nodeinfo = await getNodeInfo(instance);
|
||||
const deliveryProvider = getProviderFor(nodeinfo.software.name);
|
||||
|
||||
let username: string;
|
||||
if (typeof req.body.username !== "string") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "username is not a string",
|
||||
});
|
||||
}
|
||||
username = req.body.username;
|
||||
|
||||
req.session.login.username = username;
|
||||
// this is the prompt for the user
|
||||
// method is how the service is acting
|
||||
// therefore if the server is sending a code, the user needs to enter the code
|
||||
req.session.login.prompt =
|
||||
method === "SEND_CODE" ? "ENTER_CODE" : "SEND_CODE";
|
||||
|
||||
switch (method) {
|
||||
case "SEND_CODE": {
|
||||
// send code to user
|
||||
|
||||
const code = "."
|
||||
.repeat(5)
|
||||
.split("")
|
||||
.map(() => Math.floor(Math.random() * 10))
|
||||
.join("");
|
||||
|
||||
// TODO: prevent spam sending codes to someone
|
||||
|
||||
const session = await prisma.authSession.create({
|
||||
data: {
|
||||
one_time_code: code,
|
||||
mode: "SEND_CODE",
|
||||
user_sub: [username, instance].join("@"),
|
||||
},
|
||||
});
|
||||
req.session.login.session_id = session.id;
|
||||
|
||||
await deliveryProvider!.send([username, instance], "code: " + code);
|
||||
|
||||
req.session.save(() => {
|
||||
res.send({
|
||||
success: true,
|
||||
step: "CODE_SENT",
|
||||
data: {
|
||||
session_id: session.id,
|
||||
account:
|
||||
deliveryProvider!.service_account.username +
|
||||
"@" +
|
||||
deliveryProvider!.service_account.host,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "RECV_CODE": {
|
||||
// tell user to send code to service
|
||||
|
||||
const code = "."
|
||||
.repeat(5)
|
||||
.split("")
|
||||
.map(() => Math.floor(Math.random() * 10))
|
||||
.join("");
|
||||
|
||||
const session = await prisma.authSession.create({
|
||||
data: {
|
||||
one_time_code: code,
|
||||
mode: "RECV_CODE",
|
||||
user_sub: [username, instance].join("@"),
|
||||
},
|
||||
});
|
||||
req.session.login.session_id = session.id;
|
||||
|
||||
req.session.save(() => {
|
||||
res.send({
|
||||
success: true,
|
||||
step: "SEND_CODE",
|
||||
data: {
|
||||
session_id: session.id,
|
||||
message_to_send:
|
||||
ReceiveCodeProvider.fullMention() +
|
||||
" " +
|
||||
ReceiveCodeProvider.getMessageTemplate(code),
|
||||
},
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/login/step/verify", async (req, res) => {
|
||||
if (
|
||||
!req.session?.login ||
|
||||
["ENTER_CODE", "SEND_CODE"].indexOf(req.session.login.prompt) === -1
|
||||
) {
|
||||
return res.status(400).json({ success: false, error: "wrong_step" });
|
||||
}
|
||||
|
||||
const { session_id, username, instance } = req.session.login;
|
||||
|
||||
const session = await prisma.authSession.findFirst({
|
||||
where: {
|
||||
id: session_id,
|
||||
user_sub: [username, instance].join("@"),
|
||||
},
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
req.session.login = undefined;
|
||||
req.session.save(() => {
|
||||
res.status(400).json({ success: false, error: "session_lost" });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
switch (session.mode) {
|
||||
case "SEND_CODE": {
|
||||
// code has been sent, we're expecting the user to give a code
|
||||
let code: string;
|
||||
|
||||
if (typeof req.body.code !== "string") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "code is not a string" });
|
||||
}
|
||||
code = req.body.code;
|
||||
|
||||
if (session.one_time_code !== code) {
|
||||
return res.status(400).json({ success: false, error: "code_invalid" });
|
||||
}
|
||||
|
||||
req.session.user = { sub: session.user_sub };
|
||||
req.session.login = undefined;
|
||||
req.session.save(() => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "RECV_CODE": {
|
||||
// the user has notified us that they've sent the post, now to check it being delivered
|
||||
|
||||
ReceiveCodeProvider.checkUser(
|
||||
[username!, instance],
|
||||
session.one_time_code
|
||||
).then((data) => {
|
||||
if (data.success) {
|
||||
req.session.user = { sub: session.user_sub };
|
||||
req.session.login = undefined;
|
||||
req.session.save(() => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({ success: false, error: data.error });
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/****
|
||||
* API methods for getting oidc-provider metadata for the client-side render
|
||||
****/
|
||||
|
||||
app.get("/interaction", async (req, res) => {
|
||||
const { id } = req.query;
|
||||
if (typeof id !== "string") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "Missing interaction ?id" });
|
||||
}
|
||||
|
||||
const interaction = await oidc.Interaction.find(id);
|
||||
if (!interaction) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, error: "Unknown interaction" });
|
||||
}
|
||||
|
||||
res.json(interaction);
|
||||
});
|
||||
|
||||
app.get("/client", async (req, res) => {
|
||||
const { id } = req.query;
|
||||
if (typeof id !== "string") {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "Missing client ?id" });
|
||||
}
|
||||
|
||||
const client = await oidc.Client.find(id);
|
||||
if (!client) {
|
||||
return res.status(404).json({ success: false, error: "Unknown client" });
|
||||
}
|
||||
|
||||
res.json(makeClientPublic(client));
|
||||
});
|
||||
|
||||
// TODO: maybe add a security cookie?
|
||||
app.post("/interaction/:uid/confirm", async (req, res) => {
|
||||
try {
|
||||
const interaction = await oidc.Interaction.find(req.params.uid);
|
||||
if (!interaction)
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "Unknown interaction" });
|
||||
|
||||
if (interaction.prompt.name !== "consent")
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "Invalid interaction" });
|
||||
|
||||
let grant;
|
||||
if (interaction.grantId) {
|
||||
grant = await oidc.Grant.find(interaction.grantId);
|
||||
} else {
|
||||
grant = new oidc.Grant({
|
||||
accountId: interaction.session?.accountId,
|
||||
clientId: interaction.params.client_id as string,
|
||||
});
|
||||
}
|
||||
|
||||
if (interaction.prompt.details.missingOIDCScope) {
|
||||
grant!.addOIDCScope(
|
||||
(interaction.prompt.details.missingOIDCScope as any).join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
let grantId = await grant!.save();
|
||||
|
||||
let consent: any = {};
|
||||
if (!interaction.grantId) {
|
||||
// we're just modifying an existing one
|
||||
consent.grantId = grantId;
|
||||
}
|
||||
|
||||
// merge with last submission
|
||||
interaction.result = { ...interaction.lastSubmission, consent };
|
||||
await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
returnTo: interaction.returnTo,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(req.originalUrl, e);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Internal server error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: maybe add a security cookie?
|
||||
app.post("/interaction/:uid/abort", async (req, res) => {
|
||||
try {
|
||||
const interaction = await oidc.Interaction.find(req.params.uid);
|
||||
if (!interaction)
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "Unknown interaction" });
|
||||
|
||||
interaction.result = {
|
||||
error: "access_denied",
|
||||
error_description: "End-User aborted interaction",
|
||||
};
|
||||
await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
returnTo: interaction.returnTo,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(req.originalUrl, e);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: "Internal server error",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export { app as APIRouter };
|
|
@ -0,0 +1,37 @@
|
|||
interface IServiceAccount {
|
||||
username: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery method for messages
|
||||
*
|
||||
* Why is this needed?
|
||||
* - This is needed because some softwares don't understand DMs from other software
|
||||
*
|
||||
* @template T Additonal service_account params
|
||||
*/
|
||||
export abstract class DeliveryProvider<T = {}> {
|
||||
service_account: Partial<IServiceAccount & T> = {};
|
||||
|
||||
/**
|
||||
* Verify credentials and/or tokens
|
||||
*/
|
||||
abstract setup(): Promise<any>;
|
||||
|
||||
/**
|
||||
* Does this provider support this software name
|
||||
* @param software_name
|
||||
*/
|
||||
abstract isThisFor(software_name: string): boolean;
|
||||
|
||||
/**
|
||||
* Send message to user
|
||||
* @param user
|
||||
* @param content
|
||||
*/
|
||||
abstract send(
|
||||
user: [username: string, instance: string],
|
||||
content: string
|
||||
): Promise<void>;
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import { DeliveryProvider } from "./DeliveryProvider.js";
|
||||
import { LemmyCompatible } from "./lemmy.js";
|
||||
import { MastodonCompatible } from "./mastodon.js";
|
||||
import { ReceiveCodeProvider } from "./receive.js";
|
||||
|
||||
const allProviders: DeliveryProvider[] = [LemmyCompatible, MastodonCompatible];
|
||||
|
||||
export const getProviderFor = (software_name: string) =>
|
||||
allProviders.find((p) => p.isThisFor(software_name));
|
||||
|
||||
export const setupAllProviders = () =>
|
||||
Promise.allSettled([
|
||||
...allProviders.map((p) => p.setup()),
|
||||
ReceiveCodeProvider.setup(),
|
||||
]);
|
|
@ -0,0 +1,224 @@
|
|||
import { DeliveryProvider } from "./DeliveryProvider.js";
|
||||
|
||||
/**
|
||||
* Lemmy compatible DM
|
||||
*/
|
||||
class LemmyCompatible_ extends DeliveryProvider<{ token: string }> {
|
||||
async setup(): Promise<any> {
|
||||
const { LEMMY_HOST, LEMMY_USER, LEMMY_PASS, LEMMY_TOKEN } = process.env;
|
||||
|
||||
if (!LEMMY_HOST) {
|
||||
throw new Error("Missing LEMMY_HOST cannot enable Lemmy polyfill");
|
||||
}
|
||||
this.service_account.host = LEMMY_HOST;
|
||||
|
||||
if (LEMMY_TOKEN) {
|
||||
const lemmyapi = new LemmyAPI(LEMMY_HOST, LEMMY_TOKEN);
|
||||
const validToken = await lemmyapi.verifyToken();
|
||||
|
||||
if (!validToken) {
|
||||
if (!LEMMY_USER || !LEMMY_PASS) {
|
||||
throw new Error(
|
||||
"LEMMY_TOKEN is invalid and LEMMY_USER & LEMMY_PASS are not specified"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.service_account.username = validToken.name;
|
||||
this.service_account.token = LEMMY_TOKEN;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!LEMMY_USER || !LEMMY_PASS) {
|
||||
throw new Error(
|
||||
"LEMMY_TOKEN is is not set and LEMMY_USER & LEMMY_PASS are not specified"
|
||||
);
|
||||
}
|
||||
|
||||
const auth = await LemmyAPI.login(LEMMY_HOST, LEMMY_USER, LEMMY_PASS);
|
||||
if (!auth.jwt) {
|
||||
throw new Error("LEMMY_HOST, LEMMY_USER or LEMMY_PASS are incorrect");
|
||||
}
|
||||
|
||||
console.log("[LEMMY AUTH] Success! Save this token as LEMMY_TOKEN in env!");
|
||||
console.log("[LEMMY AUTH] TOKEN: " + auth.jwt);
|
||||
|
||||
this.service_account.username = LEMMY_USER;
|
||||
this.service_account.token = auth.jwt;
|
||||
}
|
||||
|
||||
isThisFor(software_name: string): boolean {
|
||||
return software_name === "lemmy";
|
||||
}
|
||||
|
||||
async send(
|
||||
user: [username: string, instance: string],
|
||||
content: string
|
||||
): Promise<void> {
|
||||
const api = new LemmyAPI(
|
||||
this.service_account.host!,
|
||||
this.service_account.token!
|
||||
);
|
||||
|
||||
await api.sendPM(user[0] + "@" + user[1], content);
|
||||
}
|
||||
}
|
||||
|
||||
interface ILemmyUser {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ILemmyAuth {
|
||||
jwt?: string;
|
||||
/**
|
||||
* Registration application not approved
|
||||
*/
|
||||
registration_created: boolean;
|
||||
/**
|
||||
* Email not verified?
|
||||
*/
|
||||
verify_email_sent: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lemmy v0.19 API wrapper
|
||||
*/
|
||||
class LemmyAPI {
|
||||
private host: string;
|
||||
private token: string;
|
||||
|
||||
static async login(
|
||||
host: string,
|
||||
username: string,
|
||||
password: string,
|
||||
totp?: string
|
||||
): Promise<ILemmyAuth> {
|
||||
const req = await fetch(`https://${host}/api/v3/user/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username_or_email: username,
|
||||
password,
|
||||
totp_2fa_token: totp,
|
||||
}),
|
||||
});
|
||||
|
||||
let data: any;
|
||||
try {
|
||||
data = await req.json();
|
||||
} catch (e) {
|
||||
throw new Error("Invalid JSON");
|
||||
}
|
||||
|
||||
if (req.status === 404 || req.status === 400) {
|
||||
if (
|
||||
data?.error === "missing_totp_token" ||
|
||||
data?.error === "incorrect_totp token"
|
||||
)
|
||||
throw new Error("totp");
|
||||
throw new Error("invalid_login");
|
||||
}
|
||||
|
||||
if (req.status !== 200) throw new Error("status code " + req.status);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
constructor(host: string, token: string) {
|
||||
this.host = host;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
private fetch<T = any>(
|
||||
endpoint: string,
|
||||
method: string = "GET",
|
||||
body: { [k: string]: any } = {}
|
||||
): Promise<T> {
|
||||
let url = `https://${this.host}${endpoint}`;
|
||||
let request: any = {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: "Bearer " + this.token,
|
||||
},
|
||||
};
|
||||
|
||||
if (method === "GET") {
|
||||
let params = new URLSearchParams();
|
||||
for (let [key, value] of Object.entries(body)) {
|
||||
params.set(key, value + "");
|
||||
}
|
||||
url += "?" + params.toString();
|
||||
} else {
|
||||
request.headers["Content-Type"] = "application/json";
|
||||
request.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
// TODO: error handling
|
||||
return fetch(url, request).then((a) => a.json() as T);
|
||||
}
|
||||
|
||||
async verifyToken(): Promise<ILemmyUser | false> {
|
||||
const req = await this.fetch("/api/v3/site");
|
||||
|
||||
if (!req.my_user || !req.my_user?.local_user_view?.person?.name)
|
||||
return false;
|
||||
|
||||
return {
|
||||
name: req.my_user.local_user_view.person.name,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* localparts can have any capitalization
|
||||
* homeservers have to be fully lowercase otherwise lemmy freaks out
|
||||
*/
|
||||
normalizeUsername(username: string): string {
|
||||
let [localpart, homeserver] = username.split("@").map((l) => l.trim());
|
||||
homeserver = homeserver.toLowerCase();
|
||||
|
||||
return localpart + "@" + homeserver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets actor id from homeserver so the bot can pm them
|
||||
*
|
||||
* @param username Username
|
||||
*/
|
||||
async getActorID(username: string): Promise<number> {
|
||||
username = this.normalizeUsername(username);
|
||||
|
||||
const actor = await this.fetch("/api/v3/user", "GET", { username });
|
||||
|
||||
if (actor.error || !actor?.person_view?.person?.id) {
|
||||
console.info("Can't find account", this.host, username, actor);
|
||||
throw new Error("unknown_account");
|
||||
}
|
||||
|
||||
return actor?.person_view?.person?.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Lemmy PM to a user via the homeserver the bot is on
|
||||
*
|
||||
* @param username username
|
||||
* @param content content
|
||||
*/
|
||||
async sendPM(username: string, content: string) {
|
||||
console.log("attempting ", username);
|
||||
const actorId = await this.getActorID(username);
|
||||
|
||||
const pm = await this.fetch("/api/v3/private_message", "POST", {
|
||||
content,
|
||||
recipient_id: actorId,
|
||||
});
|
||||
|
||||
if (pm.error) {
|
||||
console.error("create pm", { username, content }, pm);
|
||||
throw new Error("Failed to send direct message");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const LemmyCompatible = new LemmyCompatible_();
|
|
@ -0,0 +1,81 @@
|
|||
import { DeliveryProvider } from "./DeliveryProvider.js";
|
||||
|
||||
/**
|
||||
* Mastodon compatible DM
|
||||
*/
|
||||
class MastodonCompatible_ extends DeliveryProvider<{ token: string }> {
|
||||
async setup() {
|
||||
const { MASTODON_HOST, MASTODON_USER, MASTODON_TOKEN } = process.env;
|
||||
|
||||
if (!MASTODON_HOST) {
|
||||
throw new Error("Missing MASTODON_HOST cannot enable Mastodon polyfill");
|
||||
}
|
||||
this.service_account.host = MASTODON_HOST;
|
||||
|
||||
if (!MASTODON_TOKEN) {
|
||||
throw new Error(
|
||||
"Missing MASTODON_TOKEN cannot enable Mastodon polyfill (you can get this from Settings -> Development -> <application> -> Access Token"
|
||||
);
|
||||
}
|
||||
|
||||
const userCheckReq = await fetch(
|
||||
`https://${MASTODON_HOST}/api/v1/accounts/verify_credentials`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: "Bearer " + MASTODON_TOKEN,
|
||||
},
|
||||
}
|
||||
);
|
||||
const userCheck: any = await userCheckReq.json();
|
||||
|
||||
if (userCheckReq.status !== 200) {
|
||||
throw new Error(
|
||||
"MASTODON_TOKEN is invalid (server responded with " +
|
||||
userCheckReq.status +
|
||||
") " +
|
||||
userCheck
|
||||
);
|
||||
}
|
||||
|
||||
// the return type from the API is version tagged & status code checked, assume username property is there
|
||||
this.service_account.username = userCheck.username;
|
||||
this.service_account.token = MASTODON_TOKEN;
|
||||
}
|
||||
|
||||
isThisFor(software_name: string): boolean {
|
||||
// TODO: add other softwares that work w/ mastodon DMs
|
||||
return software_name === "mastodon";
|
||||
}
|
||||
|
||||
async send(
|
||||
user: [username: string, instance: string],
|
||||
content: string
|
||||
): Promise<void> {
|
||||
const form = new URLSearchParams();
|
||||
form.set("status", `${content}\n\n@${user[0]}@${user[1]}`);
|
||||
form.set("visibility", "direct");
|
||||
|
||||
const api = await fetch(
|
||||
`https://${this.service_account.host}/api/v1/statuses`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: "Bearer " + this.service_account.token,
|
||||
},
|
||||
body: form,
|
||||
}
|
||||
);
|
||||
const apiResponse = await api.json();
|
||||
|
||||
if (api.status !== 200) {
|
||||
console.error(
|
||||
"Failed to send Mastodon DM",
|
||||
{ user, content },
|
||||
apiResponse
|
||||
);
|
||||
throw new Error("Failed to send Mastodon DM");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MastodonCompatible = new MastodonCompatible_();
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* Fallback for sending messages to a bot
|
||||
*/
|
||||
class ReceiveCodeProvider_ {
|
||||
service_account: {
|
||||
username: string;
|
||||
host: string;
|
||||
token: string;
|
||||
} = {} as any;
|
||||
|
||||
async setup() {
|
||||
const { MASTODON_HOST, MASTODON_USER, MASTODON_TOKEN } = process.env;
|
||||
|
||||
if (!MASTODON_USER) {
|
||||
throw new Error("Missing MASTODON_USER cannot enable receive provider");
|
||||
}
|
||||
this.service_account.username = MASTODON_USER;
|
||||
|
||||
if (!MASTODON_HOST) {
|
||||
throw new Error("Missing MASTODON_HOST cannot enable receive provider");
|
||||
}
|
||||
this.service_account.host = MASTODON_HOST;
|
||||
|
||||
if (!MASTODON_TOKEN) {
|
||||
throw new Error(
|
||||
"Missing MASTODON_TOKEN cannot enable receive provider (you can get this from Settings -> Development -> <application> -> Access Token"
|
||||
);
|
||||
}
|
||||
|
||||
const userCheckReq = await fetch(
|
||||
`https://${MASTODON_HOST}/api/v1/accounts/verify_credentials`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: "Bearer " + MASTODON_TOKEN,
|
||||
},
|
||||
}
|
||||
);
|
||||
const userCheck = await userCheckReq.json();
|
||||
|
||||
if (userCheckReq.status !== 200) {
|
||||
throw new Error(
|
||||
"MASTODON_TOKEN is invalid (server responded with " +
|
||||
userCheckReq.status +
|
||||
") " +
|
||||
userCheck
|
||||
);
|
||||
}
|
||||
|
||||
this.service_account.token = MASTODON_TOKEN;
|
||||
}
|
||||
|
||||
getMessageTemplate(code: string) {
|
||||
return `authorization code: ${code}`;
|
||||
}
|
||||
|
||||
fullMention() {
|
||||
return `@${this.service_account.username}@${this.service_account.host}`;
|
||||
}
|
||||
|
||||
async checkUser(
|
||||
user: [username: string, instance: string],
|
||||
code: string
|
||||
): Promise<
|
||||
| {
|
||||
success: true;
|
||||
}
|
||||
| { success: false; error: string }
|
||||
> {
|
||||
let accreq_data = new URLSearchParams();
|
||||
accreq_data.set("acct", user[0] + "@" + user[1]);
|
||||
const accreq = await fetch(
|
||||
`https://${this.service_account.host}/api/v1/accounts/lookup?` +
|
||||
accreq_data
|
||||
);
|
||||
const accres = (await accreq.json()) as { error: string } | { id: string };
|
||||
|
||||
if (accreq.status !== 200 || "error" in accres) {
|
||||
console.error("Failed to query webfinger -> Mastodon ID", user, accres);
|
||||
return { success: false, error: "failed_to_find_user" };
|
||||
}
|
||||
|
||||
// local account ID, this will be used to filter notifications
|
||||
const account_id = accres.id;
|
||||
|
||||
let notifreq_data = new URLSearchParams();
|
||||
notifreq_data.set("types", "mention");
|
||||
notifreq_data.set("account_id", account_id);
|
||||
const notifreq = await fetch(
|
||||
`https://${this.service_account.host}/api/v1/notifications?` +
|
||||
notifreq_data,
|
||||
{
|
||||
headers: {
|
||||
Authorization: "Bearer " + this.service_account.token,
|
||||
},
|
||||
}
|
||||
);
|
||||
const notifres = (await notifreq.json()) as
|
||||
| { status: { content: string } }[]
|
||||
| { error: string };
|
||||
|
||||
if (notifreq.status !== 200 || "error" in notifres) {
|
||||
console.error("Failed to get mentions", notifres);
|
||||
return { success: false, error: "internal_error" };
|
||||
}
|
||||
|
||||
const mostRecentMention = notifres?.[0]?.status;
|
||||
if (!mostRecentMention) {
|
||||
return { success: false, error: "no_mentions" };
|
||||
}
|
||||
|
||||
if (
|
||||
mostRecentMention.content?.indexOf(this.getMessageTemplate(code)) === -1
|
||||
) {
|
||||
console.log(mostRecentMention.content);
|
||||
return { success: false, error: "message_content_invalid" };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
export const ReceiveCodeProvider = new ReceiveCodeProvider_();
|
|
@ -0,0 +1,84 @@
|
|||
import express from "express";
|
||||
import session from "express-session";
|
||||
import cors from "cors";
|
||||
import bodyParser from "body-parser";
|
||||
import path from "path";
|
||||
import { oidc } from "./oidc.js";
|
||||
import { makeClientPublic } from "./utils.js";
|
||||
|
||||
import "../types/session-types.js";
|
||||
import { APIRouter } from "./api.js";
|
||||
|
||||
export const app = express();
|
||||
|
||||
if (process.env.NODE_ENV === "production") app.set("trust proxy", 1);
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.CLIENT_HOST!,
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
app.use(bodyParser.json());
|
||||
|
||||
app.use(
|
||||
session({
|
||||
secret: process.env.SESSION_SECRET!,
|
||||
resave: false,
|
||||
saveUninitialized: true,
|
||||
cookie: {
|
||||
secure:
|
||||
process.env.NODE_ENV === "production" && !process.env.USE_INSECURE,
|
||||
sameSite: "lax",
|
||||
},
|
||||
// TODO: do not use memory store
|
||||
})
|
||||
);
|
||||
|
||||
app.use("/interaction/:uid", async (req, res, next) => {
|
||||
const interaction = await oidc.Interaction.find(req.params.uid);
|
||||
if (interaction?.prompt.name === "login") {
|
||||
if (typeof req.session.user === "undefined") {
|
||||
res.redirect("/login?return=" + encodeURIComponent(req.originalUrl));
|
||||
} else {
|
||||
const returnTo = await oidc.interactionResult(req, res, {
|
||||
login: { accountId: req.session.user.sub },
|
||||
});
|
||||
|
||||
req.session.destroy(() => {
|
||||
res.redirect(returnTo);
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
if (process.env.CLIENT_HOST) {
|
||||
app.get(["/interaction*", "/login"], (req, res) => {
|
||||
const url = new URL(req.originalUrl, process.env.CLIENT_HOST!);
|
||||
res.redirect(url.toString());
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.SERVE_FRONTEND) {
|
||||
const indexFile = path.join(process.env.SERVE_FRONTEND, "index.html");
|
||||
|
||||
app.use(express.static(process.env.SERVE_FRONTEND));
|
||||
app.get(["/", "/interaction*", "/login"], (req, res) => {
|
||||
res.sendFile(indexFile);
|
||||
});
|
||||
} else {
|
||||
app.get("/", (req, res) => {
|
||||
res.send("fediverse-auth");
|
||||
});
|
||||
}
|
||||
|
||||
app.use("/api/v1", APIRouter);
|
||||
|
||||
app.use(oidc.callback());
|
|
@ -0,0 +1,13 @@
|
|||
import packageJSON from "../../package.json" with { type: "json" };
|
||||
|
||||
export const safe_fetch: typeof fetch = (url, params) => {
|
||||
const args = {
|
||||
...params,
|
||||
headers: {
|
||||
...params?.headers,
|
||||
"User-Agent": `fediverse-auth/${packageJSON.version} (FediverseAuth; +https://git.sc07.company/sc07/fediverse-auth)`,
|
||||
},
|
||||
};
|
||||
|
||||
return fetch(url, args);
|
||||
};
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* Utility functions for getting instance-specific metadata
|
||||
*/
|
||||
|
||||
import { NodeInfo } from "../../types/nodeinfo.js";
|
||||
import { getNodeInfo } from "../nodeinfo.js";
|
||||
|
||||
export interface IInstance {
|
||||
software: {
|
||||
name: string;
|
||||
version: string;
|
||||
logo_uri?: string;
|
||||
repository?: string;
|
||||
homepage?: string;
|
||||
};
|
||||
instance: {
|
||||
/**
|
||||
* Untrusted URL
|
||||
*/
|
||||
logo_uri?: string;
|
||||
/**
|
||||
* Untrusted URL
|
||||
*/
|
||||
banner_uri?: string;
|
||||
name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get instance metadata from hostname
|
||||
*
|
||||
* TODO: write software#logo_uri
|
||||
* TODO: write instance (software specific)
|
||||
*
|
||||
* @throws NodeInfo_Invalid|NodeInfo_Unsupported if nodeinfo param is not provided
|
||||
* @param instance_hostname
|
||||
* @param nodeinfo
|
||||
*/
|
||||
export const getInstanceMeta = async (
|
||||
instance_hostname: string,
|
||||
nodeinfo?: NodeInfo
|
||||
): Promise<IInstance> => {
|
||||
let _nodeinfo: NodeInfo | undefined = nodeinfo;
|
||||
|
||||
if (!_nodeinfo) {
|
||||
_nodeinfo = await getNodeInfo(instance_hostname);
|
||||
}
|
||||
|
||||
return {
|
||||
software: {
|
||||
name: _nodeinfo.software.name,
|
||||
version: _nodeinfo.software.version,
|
||||
...("repository" in _nodeinfo.software && {
|
||||
repository: _nodeinfo.software.repository,
|
||||
}),
|
||||
...("homepage" in _nodeinfo.software && {
|
||||
homepage: _nodeinfo.software.homepage,
|
||||
}),
|
||||
},
|
||||
instance: {},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* Utility functions for parsing NodeInfo
|
||||
*
|
||||
* @see https://nodeinfo.diaspora.software/protocol.html
|
||||
*/
|
||||
|
||||
import {
|
||||
NODEINFO_SCHEMA_NAMESPACE,
|
||||
SUPPORTED_NODEINFO_VERSIONS,
|
||||
type NodeInfo,
|
||||
} from "../types/nodeinfo.js";
|
||||
import { safe_fetch } from "./fetch.js";
|
||||
|
||||
/**
|
||||
* Gets NodeInfo from hostname provided
|
||||
*
|
||||
* TODO: cache this
|
||||
*
|
||||
* 1. Read /.well-known/nodeinfo
|
||||
* 2. Read most up to date version known found
|
||||
*
|
||||
* @throws NodeInfo_Invalid
|
||||
* @throws NodeInfo_Unsupported
|
||||
* @param instance_hostname
|
||||
*/
|
||||
export const getNodeInfo = async (
|
||||
instance_hostname: string
|
||||
): Promise<NodeInfo> => {
|
||||
const nodeinfo = await readWKNodeInfo(instance_hostname);
|
||||
return readNodeInfo(nodeinfo);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads hostname's /.well-known/nodeinfo, validates & picks latest supported version
|
||||
*
|
||||
* This does not verify the full `Content-Type` header specified in the spec
|
||||
*
|
||||
* @see https://nodeinfo.diaspora.software/protocol.html
|
||||
* @param hostname
|
||||
* @returns Latest supported version's nodeinfo URL
|
||||
*/
|
||||
const readWKNodeInfo = async (hostname: string): Promise<string> => {
|
||||
const WK_URL = `https://${hostname}/.well-known/nodeinfo`;
|
||||
|
||||
const req = await safe_fetch(WK_URL);
|
||||
|
||||
if (req.status !== 200) {
|
||||
throw new NodeInfo_Invalid();
|
||||
}
|
||||
|
||||
let data: any;
|
||||
try {
|
||||
data = await req.json();
|
||||
} catch (e) {
|
||||
throw new NodeInfo_Invalid();
|
||||
}
|
||||
|
||||
// links that have the required props & types & are in the nodeinfo schema ns
|
||||
// sorted by ascii value in reverse order
|
||||
const validNodeInfoOptions: {
|
||||
rel: string;
|
||||
href: string;
|
||||
}[] = data?.links
|
||||
?.filter(
|
||||
(link: any) =>
|
||||
typeof link.rel === "string" &&
|
||||
typeof link.href === "string" &&
|
||||
link.rel.indexOf(NODEINFO_SCHEMA_NAMESPACE) === 0
|
||||
)
|
||||
.map((a: any) => ({
|
||||
...a,
|
||||
rel: a.rel.replace(NODEINFO_SCHEMA_NAMESPACE, ""),
|
||||
}))
|
||||
.sort((a: any, b: any) => b.rel - a.rel);
|
||||
|
||||
let validVersion: string | undefined;
|
||||
for (let i = 0; i < validNodeInfoOptions.length; i++) {
|
||||
// this should return a version number
|
||||
let version = validNodeInfoOptions[i].rel;
|
||||
|
||||
if (SUPPORTED_NODEINFO_VERSIONS.indexOf(version) > -1) {
|
||||
// this version is supported
|
||||
validVersion = version;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!validVersion) throw new NodeInfo_Invalid();
|
||||
|
||||
return validNodeInfoOptions.find((v) => v.rel === validVersion)!.href;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the NodeInfo document specified
|
||||
*
|
||||
* @note This should be treated as user-supplied content
|
||||
* @note No properties are checked, everything is assumed to follow the spec
|
||||
*
|
||||
* @see https://nodeinfo.diaspora.software/schema.html
|
||||
* @param full_url
|
||||
*/
|
||||
const readNodeInfo = async <
|
||||
T extends NodeInfo["version"] = NodeInfo["version"]
|
||||
>(
|
||||
full_url: string
|
||||
): Promise<NodeInfo & { version: T }> => {
|
||||
const req = await safe_fetch(full_url);
|
||||
|
||||
if (req.status !== 200) {
|
||||
throw new NodeInfo_Invalid();
|
||||
}
|
||||
|
||||
let data: any;
|
||||
try {
|
||||
data = await req.json();
|
||||
} catch (e) {
|
||||
throw new NodeInfo_Invalid();
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/****
|
||||
* Errors
|
||||
****/
|
||||
|
||||
/**
|
||||
* Thrown when unable to parse a nodeinfo document
|
||||
*/
|
||||
class NodeInfo_Invalid extends Error {
|
||||
constructor() {
|
||||
super();
|
||||
this.name = "NodeInfo_Invalid";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when NodeInfo version found is unsupported
|
||||
*/
|
||||
class NodeInfo_Unsupported extends Error {
|
||||
constructor() {
|
||||
super();
|
||||
this.name = "NodeInfo_Unsupported";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,113 @@
|
|||
import Provider from "oidc-provider";
|
||||
import fs from "node:fs";
|
||||
import { PrismaAdapter } from "./adapter.js";
|
||||
import { Issuer } from "openid-client";
|
||||
|
||||
/**
|
||||
* ⚠ DEVELOPMENT KEYS ⚠
|
||||
*
|
||||
* These should not be used in production
|
||||
*/
|
||||
const DEV_KEYS = {
|
||||
// oidc-provider jwk is required
|
||||
jwks: [
|
||||
{
|
||||
p: "5uVHvbpHt2dlAn5_JIDXWmXIw_GkOJubXbHq8MY-398oY36aKOqr0ej4qIYO2dfzuJbXqLPDwRjp0t-KJZ8I7AZUbDAolF9x4bAOY1k5_t1BcI1GJZb6heK3Rqk3us_LiMSIhbTJYGZq8HTlabqcVAau2KhcCpYQUOJvHCVnzZs",
|
||||
kty: "RSA",
|
||||
q: "u9EmNAX0fxNPsUjMT-V4nSH_dSi5-iXNkAg-5ANOcivaggRpRdxPIN8d074guGFdsgWrejFBkj-8zS0UcG_3E78TOj7ZAXFjd08CVCTyKaKlkDu94_p746pc4iVOieIWFlNwajqbfuJfaFXftwd6G43-cDav-fcaMg4vtJGyybE",
|
||||
d: "cMA1KBZAq0hfuRU6q8j1nuXLRMmgWeKtA97z8oZyq2YW_WqHFX7dMWi5-o1qLXU0BcLCQOM5PEdH9KXtstk-8Oqf5NGpLJ0Fy_2l6ANXPyoMhk0wKag2EFQwU5Dyl-4RgDWLqXelF_u6arFSoWUmAuT-ZJkMfPmJp0Eo13TkzYviFX-sGsuNjhVgz1wL5YN0jvdup7XdEgnlFRbd6bf7TQ9AJsOXlJt4H4EARRh_XzFNn_y09_JIpvHFu7D6zPbAHZAj05smbuO6fMIYNOpT5uNgn0GINBwO3S-ZwmhClhpcDlLk8boLGrJI85Y6AqHF6r19Rl44EQADwLw2UceHYQ",
|
||||
e: "AQAB",
|
||||
use: "sig",
|
||||
kid: "sig-1715032435",
|
||||
qi: "A7QcvALvsMumHGyE59510xUBl4DZKCLjaXV8C6D-FxpXsQF8LlDYPbpJDbNo3_NbzCZzQ8VU3DbyKnb_RSlMw8RgV24_VyaojwkArij8twMtA5usUK5BFachCscAudgI_qR76HK_1KOLhyX5BMfpxiaJLzxi9aY6Vp8cR1-u7Tc",
|
||||
dp: "f47_eVOmNy6J4TzdJN-BGdHNfmLK5PMifDrEnswHBEsW1xCkPiKXVdotNX0KS1NAtCOxdOQLK2yGEReqDGq11R8SGMrqQD4SfipzaHNs1N6LPpDtxeqI8Np1gjYOMciGm0JoYeWksvsh7UHHVAfiQZGHmu44GykYTncqBxSrKi0",
|
||||
alg: "RS256",
|
||||
dq: "Sild4nDviDYB48kRFANSSwmfq413UjUXJGZ9Ht_HXAHA-FHxl6pUfHBdgLy0gtm_e4oNmeRVNgCA9qt0RKmRcHSkjP1ABvfVBMln6_3iuVoF8hwE8T55KP6eSpXcm0lw20P7QZb-y21rqvEts0H6j2LUM08E0bkm2NkNMUnOxSE",
|
||||
n: "qWYfDB2PM0H88fAwLS_kKGufLlnDWYKUJ0v-P4CDwI09PMGTxNrHVsfNpFyu7bcbdyhP-h3QWs3XYE-kB5-HTfmtoosuzgRkaIHT13dTGS24smtmiZ-9SZPp3Jh1eUT-z24-TvQwhT-gEnkmcER_Ee9UIDpRb0b2aMX89q9M8pOuxm0b0Jth2l2mlYPrVTOChabW6H-ekcXuX2c02CQeIOGmwes56bMyFmOia2e7WaQfmVPVhOp5SHqpHoR2IeIIqIC8QgixRpop5xJLlaOJ9QI5qa76iUn33oob30GgqngDuRMgBvT42lZX6TfS-R-ToqoamHFwGmXIw79yHfnbKw",
|
||||
},
|
||||
],
|
||||
// oidc-provider cookies.keys is not required
|
||||
cookies: undefined,
|
||||
};
|
||||
|
||||
const jwks_keys = process.env.OIDC_JWK_KEYS_FILE
|
||||
? JSON.parse(fs.readFileSync(process.env.OIDC_JWK_KEYS_FILE!, "utf8"))
|
||||
: DEV_KEYS.jwks;
|
||||
|
||||
const cookies_keys = process.env.OIDC_COOKIE_KEYS_FILE
|
||||
? JSON.parse(fs.readFileSync(process.env.OIDC_COOKIE_KEYS_FILE!, "utf8"))
|
||||
: DEV_KEYS.cookies;
|
||||
|
||||
export const oidc = new Provider(process.env.OIDC_ISSUER!, {
|
||||
adapter: PrismaAdapter,
|
||||
async findAccount(ctx, sub, token) {
|
||||
return {
|
||||
accountId: sub,
|
||||
async claims(use, scope, claims, rejected) {
|
||||
return { sub };
|
||||
},
|
||||
};
|
||||
},
|
||||
jwks: {
|
||||
keys: jwks_keys,
|
||||
},
|
||||
cookies: {
|
||||
keys: cookies_keys,
|
||||
},
|
||||
pkce: { required: (ctx, client) => false },
|
||||
features: {
|
||||
devInteractions: { enabled: false },
|
||||
userinfo: { enabled: true },
|
||||
registration: {
|
||||
enabled: true,
|
||||
initialAccessToken: process.env.OIDC_REGISTRATION_TOKEN!,
|
||||
},
|
||||
registrationManagement: { enabled: true },
|
||||
revocation: { enabled: true },
|
||||
rpInitiatedLogout: {
|
||||
enabled: true,
|
||||
// logoutSource(ctx, form) {
|
||||
// ctx.type = "html";
|
||||
// ctx.body = "<h1>hi</h1> " + form;
|
||||
// },
|
||||
// postLogoutSuccessSource(ctx) {
|
||||
|
||||
// }
|
||||
},
|
||||
},
|
||||
routes: {
|
||||
authorization: "/api/oidc/auth",
|
||||
backchannel_authentication: "/api/oidc/backchannel",
|
||||
code_verification: "api/oidc/device",
|
||||
device_authorization: "/api/oidc/device/auth",
|
||||
end_session: "/logout",
|
||||
introspection: "/api/oidc/token/introspection",
|
||||
jwks: "/api/oidc/jwks",
|
||||
pushed_authorization_request: "/api/oidc/request",
|
||||
registration: "/api/oidc/registration",
|
||||
revocation: "/api/oidc/token/revoke",
|
||||
token: "/api/oidc/token",
|
||||
userinfo: "/api/oidc/me",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if instance supports OIDC discovery and dynamic client registration
|
||||
* @param instance_hostname
|
||||
* @returns
|
||||
*/
|
||||
export const doesInstanceSupportOIDC = async (instance_hostname: string) => {
|
||||
let issuer: Issuer;
|
||||
|
||||
try {
|
||||
issuer = await Issuer.discover("https://" + instance_hostname);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof issuer.metadata.registration_endpoint === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
export const prisma = new PrismaClient();
|
|
@ -0,0 +1,57 @@
|
|||
import { NodeInfo } from "../types/nodeinfo.js";
|
||||
import { IOIDC_Public_Client } from "../types/oidc.js";
|
||||
import { getNodeInfo } from "./nodeinfo.js";
|
||||
import { oidc } from "./oidc.js";
|
||||
|
||||
/**
|
||||
* Domain name regex
|
||||
* @note this allows for domain names that don't have periods in them, unlikely, but possible
|
||||
* @see https://stackoverflow.com/a/26987741
|
||||
*/
|
||||
export const DOMAIN_REGEX =
|
||||
/^(((?!-))(xn--|_)?[a-z0-9-]{0,61}[a-z0-9]{1,1}\.)*(xn--)?([a-z0-9][a-z0-9\-]{0,60}|[a-z0-9-]{1,30}\.[a-z]{2,})$/g;
|
||||
|
||||
type IPrivateClient = Awaited<ReturnType<typeof oidc.Client.find>>;
|
||||
|
||||
export const makeClientPublic = (
|
||||
client: IPrivateClient
|
||||
): IOIDC_Public_Client | undefined => {
|
||||
if (!client) return undefined;
|
||||
|
||||
return {
|
||||
clientId: client.clientId,
|
||||
redirectUris: client.redirectUris!,
|
||||
clientName: client.clientName,
|
||||
clientUri: client.clientUri,
|
||||
logoUri: client.logoUri,
|
||||
policyUri: client.policyUri,
|
||||
tosUri: client.tosUri,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify if the instance at the domain is allowed
|
||||
*
|
||||
* Verifies:
|
||||
* 1. has a NodeInfo
|
||||
* 2. the NodeInfo is a supported version (2.0|2.1)
|
||||
* 3. the NodeInfo announces activitypub support
|
||||
*
|
||||
* @param instance_hostname
|
||||
*/
|
||||
export const isInstanceDomainValid = async (
|
||||
instance_hostname: string
|
||||
): Promise<boolean> => {
|
||||
let nodeinfo: NodeInfo;
|
||||
try {
|
||||
nodeinfo = await getNodeInfo(instance_hostname);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nodeinfo.version !== "2.0" && nodeinfo.version !== "2.1") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return nodeinfo.protocols.indexOf("activitypub") > -1;
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
declare global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
NODE_ENV: "development" | "production";
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,309 @@
|
|||
export type NodeInfo = NodeInfo1_0 | NodeInfo1_1 | NodeInfo2_0 | NodeInfo2_1;
|
||||
export const SUPPORTED_NODEINFO_VERSIONS = ["1.0", "1.1", "2.0", "2.1"];
|
||||
export const NODEINFO_SCHEMA_NAMESPACE =
|
||||
"http://nodeinfo.diaspora.software/ns/schema/";
|
||||
|
||||
/**
|
||||
* NodeInfo Usage
|
||||
*
|
||||
* This is the same between all the versions
|
||||
*/
|
||||
type NodeInfo_Usage = {
|
||||
users: {
|
||||
total: number;
|
||||
activeHalfyear: number;
|
||||
activeMonth: number;
|
||||
};
|
||||
localPosts: number;
|
||||
localComments: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* NodeInfo 1.0 specification
|
||||
*
|
||||
* @see https://nodeinfo.diaspora.software/schema.html
|
||||
*/
|
||||
type NodeInfo1_0 = {
|
||||
version: "1.0";
|
||||
software: {
|
||||
name: "diaspora" | "friendica" | "redmatrix";
|
||||
version: string;
|
||||
};
|
||||
protocols: {
|
||||
inbound: (
|
||||
| "buddycloud"
|
||||
| "diaspora"
|
||||
| "friendica"
|
||||
| "gnusocial"
|
||||
| "libertree"
|
||||
| "mediagoblin"
|
||||
| "pumpio"
|
||||
| "redmatrix"
|
||||
| "smtp"
|
||||
| "tent"
|
||||
)[];
|
||||
outbound: (
|
||||
| "buddycloud"
|
||||
| "diaspora"
|
||||
| "friendica"
|
||||
| "gnusocial"
|
||||
| "libertree"
|
||||
| "mediagoblin"
|
||||
| "pumpio"
|
||||
| "redmatrix"
|
||||
| "smtp"
|
||||
| "tent"
|
||||
)[];
|
||||
};
|
||||
services: {
|
||||
inbound: ("appnet" | "gnusocial" | "pumpio")[];
|
||||
outbound: (
|
||||
| "appnet"
|
||||
| "blogger"
|
||||
| "buddycloud"
|
||||
| "diaspora"
|
||||
| "dreamwidth"
|
||||
| "drupal"
|
||||
| "facebook"
|
||||
| "friendica"
|
||||
| "gnusocial"
|
||||
| "google"
|
||||
| "insanejournal"
|
||||
| "libertree"
|
||||
| "linkedin"
|
||||
| "livejournal"
|
||||
| "mediagoblin"
|
||||
| "myspace"
|
||||
| "pinterest"
|
||||
| "posterous"
|
||||
| "pumpio"
|
||||
| "redmatrix"
|
||||
| "smtp"
|
||||
| "tent"
|
||||
| "tumblr"
|
||||
| "twitter"
|
||||
| "wordpress"
|
||||
| "xmpp"
|
||||
)[];
|
||||
};
|
||||
openRegistrations: boolean;
|
||||
usage: NodeInfo_Usage;
|
||||
metadata: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* NodeInfo 1.1 specification
|
||||
*
|
||||
* @see https://nodeinfo.diaspora.software/schema.html
|
||||
*/
|
||||
type NodeInfo1_1 = {
|
||||
version: "1.1";
|
||||
software: {
|
||||
name: "diaspora" | "friendica" | "hubzilla" | "redmatrix";
|
||||
version: string;
|
||||
};
|
||||
protocols: {
|
||||
inbound: (
|
||||
| "buddycloud"
|
||||
| "diaspora"
|
||||
| "friendica"
|
||||
| "gnusocial"
|
||||
| "libertree"
|
||||
| "mediagoblin"
|
||||
| "pumpio"
|
||||
| "redmatrix"
|
||||
| "smtp"
|
||||
| "tent"
|
||||
| "zot"
|
||||
)[];
|
||||
outbound: (
|
||||
| "buddycloud"
|
||||
| "diaspora"
|
||||
| "friendica"
|
||||
| "gnusocial"
|
||||
| "libertree"
|
||||
| "mediagoblin"
|
||||
| "pumpio"
|
||||
| "redmatrix"
|
||||
| "smtp"
|
||||
| "tent"
|
||||
| "zot"
|
||||
)[];
|
||||
};
|
||||
services: {
|
||||
inbound: ("appnet" | "gnusocial" | "pumpio")[];
|
||||
outbound: (
|
||||
| "appnet"
|
||||
| "blogger"
|
||||
| "buddycloud"
|
||||
| "diaspora"
|
||||
| "dreamwidth"
|
||||
| "drupal"
|
||||
| "facebook"
|
||||
| "friendica"
|
||||
| "gnusocial"
|
||||
| "google"
|
||||
| "insanejournal"
|
||||
| "libertree"
|
||||
| "linkedin"
|
||||
| "livejournal"
|
||||
| "mediagoblin"
|
||||
| "myspace"
|
||||
| "pinterest"
|
||||
| "posterous"
|
||||
| "pumpio"
|
||||
| "redmatrix"
|
||||
| "smtp"
|
||||
| "tent"
|
||||
| "tumblr"
|
||||
| "twitter"
|
||||
| "wordpress"
|
||||
| "xmpp"
|
||||
)[];
|
||||
};
|
||||
openRegistrations: boolean;
|
||||
usage: NodeInfo_Usage;
|
||||
metadata: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* NodeInfo 2.0 specification
|
||||
*
|
||||
* @see https://nodeinfo.diaspora.software/schema.html
|
||||
*/
|
||||
type NodeInfo2_0 = {
|
||||
version: "2.0";
|
||||
software: {
|
||||
name: string;
|
||||
version: string;
|
||||
};
|
||||
protocols: (
|
||||
| "activitypub"
|
||||
| "buddycloud"
|
||||
| "dfrn"
|
||||
| "diaspora"
|
||||
| "libertree"
|
||||
| "ostatus"
|
||||
| "pumpio"
|
||||
| "tent"
|
||||
| "xmpp"
|
||||
| "zot"
|
||||
)[];
|
||||
services: {
|
||||
inbound: (
|
||||
| "atom1.0"
|
||||
| "gnusocial"
|
||||
| "imap"
|
||||
| "pnut"
|
||||
| "pop3"
|
||||
| "pumpio"
|
||||
| "rss2.0"
|
||||
| "twitter"
|
||||
)[];
|
||||
outbound: (
|
||||
| "atom1.0"
|
||||
| "blogger"
|
||||
| "buddycloud"
|
||||
| "diaspora"
|
||||
| "dreamwidth"
|
||||
| "drupal"
|
||||
| "facebook"
|
||||
| "friendica"
|
||||
| "gnusocial"
|
||||
| "google"
|
||||
| "insanejournal"
|
||||
| "libertree"
|
||||
| "linkedin"
|
||||
| "livejournal"
|
||||
| "mediagoblin"
|
||||
| "myspace"
|
||||
| "pinterest"
|
||||
| "pnut"
|
||||
| "posterous"
|
||||
| "pumpio"
|
||||
| "redmatrix"
|
||||
| "rss2.0"
|
||||
| "smtp"
|
||||
| "tent"
|
||||
| "tumblr"
|
||||
| "twitter"
|
||||
| "wordpress"
|
||||
| "xmpp"
|
||||
)[];
|
||||
};
|
||||
openRegistrations: boolean;
|
||||
usage: NodeInfo_Usage;
|
||||
metadata: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* NodeInfo 2.1 specification
|
||||
*
|
||||
* @see https://nodeinfo.diaspora.software/schema.html
|
||||
*/
|
||||
type NodeInfo2_1 = {
|
||||
version: "2.1";
|
||||
software: {
|
||||
name: string;
|
||||
version: string;
|
||||
repository?: string;
|
||||
homepage?: string;
|
||||
};
|
||||
protocols: (
|
||||
| "activitypub"
|
||||
| "buddycloud"
|
||||
| "dfrn"
|
||||
| "diaspora"
|
||||
| "libertree"
|
||||
| "ostatus"
|
||||
| "pumpio"
|
||||
| "tent"
|
||||
| "xmpp"
|
||||
| "zot"
|
||||
)[];
|
||||
services: {
|
||||
inbound: (
|
||||
| "atom1.0"
|
||||
| "gnusocial"
|
||||
| "imap"
|
||||
| "pnut"
|
||||
| "pop3"
|
||||
| "pumpio"
|
||||
| "rss2.0"
|
||||
| "twitter"
|
||||
)[];
|
||||
outbound: (
|
||||
| "atom1.0"
|
||||
| "blogger"
|
||||
| "buddycloud"
|
||||
| "diaspora"
|
||||
| "dreamwidth"
|
||||
| "drupal"
|
||||
| "facebook"
|
||||
| "friendica"
|
||||
| "gnusocial"
|
||||
| "google"
|
||||
| "insanejournal"
|
||||
| "libertree"
|
||||
| "linkedin"
|
||||
| "livejournal"
|
||||
| "mediagoblin"
|
||||
| "myspace"
|
||||
| "pinterest"
|
||||
| "pnut"
|
||||
| "posterous"
|
||||
| "pumpio"
|
||||
| "redmatrix"
|
||||
| "rss2.0"
|
||||
| "smtp"
|
||||
| "tent"
|
||||
| "tumblr"
|
||||
| "twitter"
|
||||
| "wordpress"
|
||||
| "xmpp"
|
||||
)[];
|
||||
};
|
||||
openRegistrations: boolean;
|
||||
usage: NodeInfo_Usage;
|
||||
metadata: any;
|
||||
};
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Represents a client's public data
|
||||
*/
|
||||
export interface IOIDC_Public_Client {
|
||||
clientId: string;
|
||||
redirectUris: string[];
|
||||
clientName?: string;
|
||||
clientUri?: string;
|
||||
logoUri?: string;
|
||||
policyUri?: string;
|
||||
tosUri?: string;
|
||||
}
|
||||
|
||||
export interface IOIDC_Interaction {
|
||||
jti: string;
|
||||
returnTo: string;
|
||||
prompt: {
|
||||
name: string;
|
||||
details: any;
|
||||
reasons: string[];
|
||||
};
|
||||
params: {
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
};
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
declare module "express-session" {
|
||||
interface SessionData {
|
||||
user?: {
|
||||
sub: string;
|
||||
};
|
||||
login?: {
|
||||
/**
|
||||
* - USERNAME: user should enter username now
|
||||
* - ENTER_CODE: user was sent code, they need to enter it
|
||||
* - SEND_CODE: user needs to make a post
|
||||
* - OIDC: open oidc [todo]
|
||||
*/
|
||||
prompt: "USERNAME" | "ENTER_CODE" | "SEND_CODE" | "OIDC";
|
||||
instance: string;
|
||||
method: "SEND_CODE" | "RECV_CODE"; // what delivery to attempt
|
||||
username?: string;
|
||||
session_id?: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"extends": "@tsconfig/recommended/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"inlineSourceMap": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "Node16",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "NodeNext",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["./src/**/*"]
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
services:
|
||||
fedi-auth:
|
||||
image: sc07/fediverse-auth
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- DATABASE_URL=postgres://postgres@postgres:5432/fediauth
|
||||
- OIDC_JWK_KEYS_FILE=/run/secrets/oidc_jwk_keys
|
||||
- OIDC_COOKIE_KEYS_FILE=/run/secrets/oidc_cookie_keys
|
||||
env_file:
|
||||
- .env.local
|
||||
secrets:
|
||||
- oidc_jwk_keys
|
||||
- oidc_cookie_keys
|
||||
|
||||
postgres:
|
||||
restart: always
|
||||
image: postgres:14-alpine
|
||||
healthcheck:
|
||||
test: ['CMD', 'pg_isready', '-U', 'postgres']
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
environment:
|
||||
- 'POSTGRES_HOST_AUTH_METHOD=trust'
|
||||
|
||||
secrets:
|
||||
oidc_jwk_keys:
|
||||
file: ./secrets/jwks.json
|
||||
oidc_cookie_keys:
|
||||
file: ./secrets/cookies.json
|
|
@ -0,0 +1,7 @@
|
|||
#!/bin/sh
|
||||
|
||||
# This script runs when the docker image starts
|
||||
# It just forces all migrations to run and then starts lol
|
||||
|
||||
npx -w backend prisma migrate deploy
|
||||
npm -w backend run start
|
|
@ -0,0 +1,43 @@
|
|||
# Copy this to .env.local
|
||||
# used by docker-compose.yml
|
||||
|
||||
# REQUIRED: something random to secure session cookies
|
||||
SESSION_SECRET=SomethingRandom
|
||||
|
||||
# OPTIONAL: something that needs to be given to register clients via OpenID Connect
|
||||
OIDC_REGISTRATION_TOKEN=registration-token
|
||||
|
||||
# REQUIRED: instance identifier, including protocol and port
|
||||
OIDC_ISSUER=http://localhost:3000
|
||||
# OIDC_ISSUER=https://auth.fediverse.events
|
||||
|
||||
# OPTIONAL: set if your frontend is insecure (not-https)
|
||||
# ⚠ DO NOT set if behind a reverse proxy, it'll break cookies
|
||||
# USE_INSECURE=true
|
||||
|
||||
# === Delivery Providers ===
|
||||
|
||||
# --- Mastodon & Receive Providers
|
||||
|
||||
# hostname for instance being used
|
||||
MASTODON_HOST=
|
||||
|
||||
# username for the Mastodon user
|
||||
MASTODON_USER=
|
||||
|
||||
# access token (get from developer area)
|
||||
MASTODON_TOKEN=
|
||||
|
||||
# --- Lemmy Provider
|
||||
|
||||
# hostname for instance being used
|
||||
LEMMY_HOST=
|
||||
|
||||
# username for the Lemmy user
|
||||
LEMMY_USER=
|
||||
|
||||
# password for the Lemmy user
|
||||
LEMMY_PASS=
|
||||
|
||||
# access token for the lemmy user
|
||||
LEMMY_TOKEN=
|
|
@ -0,0 +1,19 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
],
|
||||
ignorePatterns: ["dist", ".eslintrc.cjs"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["react-refresh"],
|
||||
rules: {
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
},
|
||||
};
|
|
@ -0,0 +1,26 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
.env
|
|
@ -0,0 +1,7 @@
|
|||
# fediverse-auth Frontend
|
||||
|
||||
Built with React & Material UI using Vite
|
||||
|
||||
## Development
|
||||
|
||||
Copy `example.env` to `.env` and edit the contents
|
|
@ -0,0 +1,3 @@
|
|||
# Development Use
|
||||
# Specify the backend url
|
||||
# VITE_APP_ROOT=http://localhost:3000
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Fediverse Auth</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "@fediverse-auth/frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/styled": "^11.11.5",
|
||||
"@fontsource/roboto": "^5.0.13",
|
||||
"@mui/icons-material": "^5.15.16",
|
||||
"@mui/lab": "^5.0.0-alpha.170",
|
||||
"@mui/material": "^5.15.16",
|
||||
"localforage": "^1.10.0",
|
||||
"match-sorter": "^6.3.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.23.0",
|
||||
"sort-by": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||
"@typescript-eslint/parser": "^7.2.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,121 @@
|
|||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
IconButton,
|
||||
Link,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import "./App.css";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { PageWrapper } from "./PageWrapper";
|
||||
import { api } from "./lib/utils";
|
||||
|
||||
const UserInfo = () => {
|
||||
const [avatarEl, setAvatarEl] = useState<null | HTMLElement>(null);
|
||||
const [whoami, setWhoami] = useState<{ sub: string }>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api("/api/v1/whoami")
|
||||
.then(({ data }) => {
|
||||
setWhoami("error" in data ? undefined : data);
|
||||
})
|
||||
.catch(() => {
|
||||
// TODO: error handle
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openMenu = (ev: React.MouseEvent<HTMLElement>) => {
|
||||
setAvatarEl(ev.currentTarget);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading && <CircularProgress size="2rem" />}
|
||||
{!loading && (
|
||||
<Tooltip title="Session Info">
|
||||
<IconButton onClick={openMenu} sx={{ p: 0 }}>
|
||||
{/* TODO: avatar endpoint */}
|
||||
<Avatar alt={whoami?.sub} src="#" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Menu
|
||||
sx={{ mt: "45px" }}
|
||||
id="menu-appbar"
|
||||
anchorEl={avatarEl}
|
||||
anchorOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
keepMounted
|
||||
transformOrigin={{
|
||||
vertical: "top",
|
||||
horizontal: "right",
|
||||
}}
|
||||
open={Boolean(avatarEl)}
|
||||
onClose={() => setAvatarEl(null)}
|
||||
>
|
||||
{whoami && (
|
||||
<>
|
||||
<MenuItem disabled>Hi, @{whoami.sub}!</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem href="/logout" component="a">
|
||||
Logout
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
{!whoami && (
|
||||
<MenuItem href="/login" component="a">
|
||||
Login
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h4"
|
||||
component="div"
|
||||
textAlign="center"
|
||||
sx={{ flexGrow: 1 }}
|
||||
>
|
||||
Fediverse Auth
|
||||
</Typography>
|
||||
<UserInfo />
|
||||
</Box>
|
||||
|
||||
<Typography sx={{ mb: 1 }}>
|
||||
This service is for providing generic OpenID support to other services.
|
||||
</Typography>
|
||||
|
||||
<Typography>
|
||||
You can find my source{" "}
|
||||
<Link href="https://git.sc07.company/sc07/fediverse-auth">here</Link>.
|
||||
</Typography>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
|
@ -0,0 +1,71 @@
|
|||
import { Box, IconButton, Stack, SxProps, Typography } from "@mui/material";
|
||||
import { IOIDC_Client } from "../types/oidc";
|
||||
import LanguageIcon from "@mui/icons-material/Language";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export const ClientDetailsCard = ({
|
||||
client,
|
||||
sx,
|
||||
}: {
|
||||
client: IOIDC_Client;
|
||||
sx?: SxProps;
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
border: "1px solid rgba(255,255,255,0.15)",
|
||||
borderRadius: "5px",
|
||||
p: 1,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" gap={1} sx={{ alignItems: "center" }}>
|
||||
<Box
|
||||
sx={{
|
||||
height: "32px",
|
||||
width: "32px",
|
||||
backgroundColor: "rgba(255,255,255,0.25)",
|
||||
borderRadius: "5px",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
...(client.logoUri && {
|
||||
backgroundImage: `url(${client.logoUri})`,
|
||||
backgroundPosition: "center",
|
||||
backgroundSize: "cover",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
?
|
||||
</Box>
|
||||
<Stack direction="column" sx={{ flexGrow: 1 }}>
|
||||
<Typography>{client.clientName || "No Name"}</Typography>
|
||||
<Stack direction="row" gap={0.5}>
|
||||
{client.tosUri && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Link to={client.tosUri} style={{ color: "inherit" }}>
|
||||
Terms of Service
|
||||
</Link>
|
||||
</Typography>
|
||||
)}
|
||||
{client.policyUri && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Link to={client.policyUri} style={{ color: "inherit" }}>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack direction="row" gap={0.5}>
|
||||
{client.clientUri && (
|
||||
<IconButton href={client.clientUri} target="_blank">
|
||||
<LanguageIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,188 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { api } from "../lib/utils";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { IOIDC_Client, IOIDC_Interaction } from "../types/oidc";
|
||||
import { ClientDetailsCard } from "./ClientDetailsCard";
|
||||
import { UserInfoCard } from "./UserInfoCard";
|
||||
import { LoadingButton } from "@mui/lab";
|
||||
|
||||
export const InteractionPage = () => {
|
||||
const { id: interactionId } = useParams();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading_approve, setLoading_Approve] = useState(false);
|
||||
const [loading_deny, setLoading_Deny] = useState(false);
|
||||
const [fatalError, setFatalError] = useState<string>();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const [interaction, setInteraction] = useState<IOIDC_Interaction>();
|
||||
const [user, setUser] = useState<{ sub: string }>();
|
||||
const [client, setClient] = useState<IOIDC_Client>();
|
||||
|
||||
const refreshInteraction = (interactionId: string) => {
|
||||
return api<IOIDC_Interaction>(
|
||||
"/api/v1/interaction?id=" + interactionId
|
||||
).then((interactionReq) => {
|
||||
if (interactionReq.status === 200) {
|
||||
setInteraction(interactionReq.data);
|
||||
return interactionReq.data;
|
||||
} else {
|
||||
setFatalError((interactionReq.data as any).error || "Unknown Error");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const refreshClient = (client_id: string) => {
|
||||
return api<IOIDC_Client>("/api/v1/client?id=" + client_id).then(
|
||||
(clientReq) => {
|
||||
if (clientReq.status === 200) {
|
||||
setClient(clientReq.data);
|
||||
return clientReq.data;
|
||||
} else {
|
||||
setFatalError((clientReq.data as any).error || "Unknown Error");
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!interactionId) {
|
||||
throw new Error(
|
||||
"Interaction ID is not set? This should not be possible."
|
||||
);
|
||||
}
|
||||
|
||||
Promise.allSettled([
|
||||
api("/api/v1/whoami").then((whoami) => {
|
||||
setUser("error" in whoami.data ? undefined : whoami.data);
|
||||
}),
|
||||
refreshInteraction(interactionId).then((interaction) => {
|
||||
if (interaction) {
|
||||
return refreshClient(interaction.params.client_id);
|
||||
}
|
||||
}),
|
||||
]).finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const stopAllLoading = () => {
|
||||
setLoading(false);
|
||||
setLoading_Approve(false);
|
||||
setLoading_Deny(false);
|
||||
};
|
||||
|
||||
const doApprove = async () => {
|
||||
setLoading_Approve(true);
|
||||
api<
|
||||
{ success: true; returnTo: string } | { success: false; error: string }
|
||||
>("/api/v1/interaction/" + interactionId + "/confirm", "POST")
|
||||
.then((data) => {
|
||||
if (data.status === 200 && data.data.success) {
|
||||
window.open(data.data.returnTo, "_self");
|
||||
} else {
|
||||
setError((data.data as any).error);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
stopAllLoading();
|
||||
});
|
||||
};
|
||||
|
||||
const doDeny = async () => {
|
||||
setLoading_Deny(true);
|
||||
api<
|
||||
{ success: true; returnTo: string } | { success: false; error: string }
|
||||
>("/api/v1/interaction/" + interactionId + "/abort", "POST")
|
||||
.then((data) => {
|
||||
if (data.status === 200 && data.data.success) {
|
||||
window.open(data.data.returnTo, "_self");
|
||||
} else {
|
||||
setError((data.data as any).error);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
stopAllLoading();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
p: 5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: "min(400px,90%)" }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h5" component="div">
|
||||
Fediverse Auth
|
||||
</Typography>
|
||||
|
||||
{error && <Alert severity="error">{error}</Alert>}
|
||||
|
||||
{client && <ClientDetailsCard client={client} sx={{ mt: 1 }} />}
|
||||
{user ? (
|
||||
<UserInfoCard user={user} sx={{ mt: 1 }} />
|
||||
) : (
|
||||
<Alert severity="warning">User session lost</Alert>
|
||||
)}
|
||||
|
||||
<Typography sx={{ mt: 1 }}>
|
||||
<b>{client?.clientName || "No Name"}</b> is wanting to identify
|
||||
you and redirect you to{" "}
|
||||
<code>{interaction?.params.redirect_uri}</code>
|
||||
</Typography>
|
||||
|
||||
<Stack direction="column" gap={1} sx={{ mt: 1 }}>
|
||||
<LoadingButton
|
||||
loading={loading_approve}
|
||||
disabled={loading_deny || loading}
|
||||
variant="contained"
|
||||
color="success"
|
||||
onClick={doApprove}
|
||||
>
|
||||
Approve
|
||||
</LoadingButton>
|
||||
<LoadingButton
|
||||
loading={loading_deny}
|
||||
disabled={loading_approve || loading}
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={doDeny}
|
||||
>
|
||||
Deny
|
||||
</LoadingButton>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Dialog open={loading}>
|
||||
<DialogContent>
|
||||
<CircularProgress />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(fatalError)}>
|
||||
<DialogContent>
|
||||
<Typography variant="h5">Fatal Error</Typography>
|
||||
{fatalError}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,56 @@
|
|||
import { Box, IconButton, Stack, SxProps, Typography } from "@mui/material";
|
||||
import LogoutIcon from "@mui/icons-material/Logout";
|
||||
|
||||
export const UserInfoCard = ({
|
||||
user,
|
||||
sx,
|
||||
}: {
|
||||
user: { sub: string };
|
||||
sx?: SxProps;
|
||||
}) => {
|
||||
const [username, instance] = user.sub.split("@");
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
border: "1px solid rgba(255,255,255,0.15)",
|
||||
borderRadius: "5px",
|
||||
p: 1,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" gap={1} sx={{ alignItems: "center" }}>
|
||||
<Box
|
||||
sx={{
|
||||
height: "32px",
|
||||
width: "32px",
|
||||
backgroundColor: "rgba(255,255,255,0.25)",
|
||||
borderRadius: "5px",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
// ...(client.logoUri && {
|
||||
// backgroundImage: `url(${client.logoUri})`,
|
||||
// backgroundPosition: "center",
|
||||
// backgroundSize: "cover",
|
||||
// }),
|
||||
}}
|
||||
>
|
||||
?
|
||||
</Box>
|
||||
<Stack direction="column" sx={{ flexGrow: 1 }}>
|
||||
<Typography>@{username}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
@{instance}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" gap={0.5}>
|
||||
<IconButton href={`${import.meta.env.VITE_APP_ROOT}/logout`}>
|
||||
<LogoutIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,428 @@
|
|||
import { LoadingButton } from "@mui/lab";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
OutlinedInput,
|
||||
Stack,
|
||||
Step,
|
||||
StepContent,
|
||||
StepLabel,
|
||||
Stepper,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
|
||||
import CheckIcon from "@mui/icons-material/Check";
|
||||
import { api } from "../lib/utils";
|
||||
|
||||
enum LoginState {
|
||||
INSTANCE,
|
||||
USERNAME,
|
||||
CODE_INPUT,
|
||||
CODE_SEND,
|
||||
}
|
||||
|
||||
export const LoginPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showHow, setShowHow] = useState(false);
|
||||
const [state, setState] = useState<LoginState>(LoginState.INSTANCE);
|
||||
const [errors, setErrors] = useState<
|
||||
{
|
||||
type: "info" | "warning" | "error";
|
||||
text: string;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const instanceInput = useRef<HTMLInputElement>(null);
|
||||
const usernameInput = useRef<HTMLInputElement>(null);
|
||||
const codeInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
/**
|
||||
* Instance hostname
|
||||
*/
|
||||
const [instance, setInstance] = useState("");
|
||||
/**
|
||||
* Localpart of the webfinger username
|
||||
*/
|
||||
const [username, setUsername] = useState("");
|
||||
|
||||
/**
|
||||
* Metadata used in UI elements
|
||||
*/
|
||||
const [meta, setMeta] = useState<any>();
|
||||
/**
|
||||
* Used by CODE_SENT
|
||||
*
|
||||
* The code that was sent to the user, controls a textfield
|
||||
*/
|
||||
const [code, setCode] = useState("");
|
||||
|
||||
const handleError = (err: string | undefined) => {
|
||||
setErrors([
|
||||
{
|
||||
type: "error",
|
||||
text: err || "Unknown Error",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const copyMessageToClipboard = () => {
|
||||
navigator.clipboard
|
||||
.writeText(meta.message_to_send)
|
||||
.then(() => {
|
||||
setMeta((m: any) => ({ ...m, copied: true }));
|
||||
setTimeout(() => {
|
||||
setMeta((m: any) => ({ ...m, copied: false }));
|
||||
}, 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
alert("Unable to copy to clipboard");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
switch (state) {
|
||||
case LoginState.INSTANCE:
|
||||
instanceInput.current?.focus();
|
||||
break;
|
||||
case LoginState.USERNAME:
|
||||
usernameInput.current?.focus();
|
||||
break;
|
||||
case LoginState.CODE_INPUT:
|
||||
codeInput.current?.focus();
|
||||
break;
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
const doLogin = async () => {
|
||||
setErrors([]);
|
||||
|
||||
const tryUsernameLogin = (username: string, instance: string) => {
|
||||
return api<
|
||||
| { success: false; error: string }
|
||||
| { success: true; step: string; data: any }
|
||||
>("/api/v1/login/step/username", "POST", {
|
||||
username,
|
||||
instance,
|
||||
}).then(({ status, data }) => {
|
||||
if (data.success) {
|
||||
if (data.data) setMeta(data.data);
|
||||
|
||||
switch (data.step) {
|
||||
case "CODE_SENT":
|
||||
setState(LoginState.CODE_INPUT);
|
||||
break;
|
||||
case "SEND_CODE":
|
||||
setState(LoginState.CODE_SEND);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
handleError(`[${status}] ` + (data.error || "unknown"));
|
||||
}
|
||||
|
||||
return data;
|
||||
});
|
||||
};
|
||||
|
||||
switch (state) {
|
||||
case LoginState.INSTANCE: {
|
||||
setLoading(true);
|
||||
|
||||
let _username: string | undefined;
|
||||
let _instance = instance + "";
|
||||
|
||||
while (_instance[0] === "@") {
|
||||
_instance = _instance.substring(1);
|
||||
}
|
||||
|
||||
if (_instance.indexOf("@") > -1) {
|
||||
_username = _instance.split("@")[0];
|
||||
_instance = _instance.split("@")[1];
|
||||
}
|
||||
|
||||
// must be done twice in the case someone types grant@@grants.cafe
|
||||
_instance = _instance.replace(/@/g, "");
|
||||
|
||||
if (_username) setUsername(_username);
|
||||
setInstance(_instance);
|
||||
|
||||
api<
|
||||
{ success: false; error: string } | { success: true; step: string }
|
||||
>("/api/v1/login/step/instance", "POST", {
|
||||
domain: _instance,
|
||||
})
|
||||
.then(async ({ status, data }) => {
|
||||
if (data.success) {
|
||||
// TODO: add OIDC support
|
||||
|
||||
if (_username) {
|
||||
await tryUsernameLogin(_username, _instance);
|
||||
} else {
|
||||
setState(LoginState.USERNAME);
|
||||
}
|
||||
} else {
|
||||
handleError(`[${status}] ` + (data.error || "unknown"));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case LoginState.USERNAME: {
|
||||
setLoading(true);
|
||||
|
||||
tryUsernameLogin(username, instance).finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
break;
|
||||
}
|
||||
case LoginState.CODE_INPUT: {
|
||||
setLoading(true);
|
||||
|
||||
api("/api/v1/login/step/verify", "POST", {
|
||||
code,
|
||||
})
|
||||
.then(({ status, data }) => {
|
||||
if (data.success) {
|
||||
// NOTE: possible issue; redirecting unprotected
|
||||
if (searchParams.has("return")) {
|
||||
// do not use navigate() to bypass history navigation
|
||||
// we need the backend to preprocess the request
|
||||
window.location.href = searchParams.get("return")!;
|
||||
} else {
|
||||
navigate("/");
|
||||
}
|
||||
} else {
|
||||
handleError(`[${status}] ` + (data.error || "unknown"));
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
break;
|
||||
}
|
||||
case LoginState.CODE_SEND:
|
||||
setLoading(true);
|
||||
|
||||
api("/api/v1/login/step/verify", "POST")
|
||||
.then(({ status, data }) => {
|
||||
if (data.success) {
|
||||
// NOTE: possible issue; redirecting unprotected
|
||||
if (searchParams.has("return")) {
|
||||
// do not use navigate() to bypass history navigation
|
||||
// we need the backend to preprocess the request
|
||||
window.location.href = searchParams.get("return")!;
|
||||
} else {
|
||||
navigate("/");
|
||||
}
|
||||
} else {
|
||||
handleError(`[${status}] ` + (data.error || "unknown"));
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
p: 5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: "min(400px,90%)" }}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h5" component="div">
|
||||
Fediverse Login
|
||||
</Typography>
|
||||
|
||||
<Typography component="div">
|
||||
Welcome! Enter your Fediverse identity below to continue verifying
|
||||
who you are.
|
||||
</Typography>
|
||||
|
||||
<Button variant="text" onClick={() => setShowHow(true)}>
|
||||
How does this work?
|
||||
</Button>
|
||||
|
||||
<Stack direction="column" gap={1} sx={{ mt: 1 }}>
|
||||
{errors.map((err) => (
|
||||
<Alert key={JSON.stringify(err)} severity={err.type}>
|
||||
{err.text}
|
||||
</Alert>
|
||||
))}
|
||||
|
||||
{state === LoginState.INSTANCE && (
|
||||
<>
|
||||
<TextField
|
||||
label="Instance or full handle"
|
||||
placeholder="grants.cafe"
|
||||
value={instance}
|
||||
autoFocus
|
||||
inputRef={instanceInput}
|
||||
onChange={(e) => setInstance(e.target.value)}
|
||||
onKeyUp={(e) => e.key === "Enter" && doLogin()}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Pro tip: use your full handle (grant@toast.ooo)
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
{state === LoginState.USERNAME && (
|
||||
<>
|
||||
<TextField
|
||||
label="Username"
|
||||
placeholder="grant"
|
||||
value={username}
|
||||
autoFocus
|
||||
inputRef={usernameInput}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyUp={(e) => e.key === "Enter" && doLogin()}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<Typography color="text.secondary">{"@"}</Typography>
|
||||
),
|
||||
endAdornment: (
|
||||
<Typography color="text.secondary">
|
||||
{"@" + instance}
|
||||
</Typography>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{state === LoginState.CODE_INPUT && (
|
||||
<>
|
||||
<Alert severity="info">
|
||||
You should receive a message from <b>{meta.account}</b> with
|
||||
your code
|
||||
</Alert>
|
||||
<TextField
|
||||
label="The code you got sent"
|
||||
placeholder="12345"
|
||||
value={code}
|
||||
autoFocus
|
||||
inputRef={codeInput}
|
||||
type="number"
|
||||
inputProps={{
|
||||
pattern: "[0-9]*",
|
||||
}}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
onKeyUp={(e) => e.key === "Enter" && doLogin()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{state === LoginState.CODE_SEND && (
|
||||
<>
|
||||
<Alert severity="info">
|
||||
<b>I don't know how to send you a message!</b>
|
||||
<br />
|
||||
You will need to make a post using your account
|
||||
</Alert>
|
||||
<OutlinedInput
|
||||
label="Post content"
|
||||
disabled
|
||||
value={meta.message_to_send}
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
copyMessageToClipboard();
|
||||
}}
|
||||
edge="end"
|
||||
>
|
||||
{meta.copied ? <CheckIcon /> : <ContentCopyIcon />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ display: "flex", mt: 1 }}>
|
||||
{state !== LoginState.INSTANCE && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setState(LoginState.INSTANCE)}
|
||||
>
|
||||
Start Over
|
||||
</Button>
|
||||
)}
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
variant="contained"
|
||||
onClick={doLogin}
|
||||
>
|
||||
Next
|
||||
</LoadingButton>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Dialog open={loading}>
|
||||
<DialogContent>
|
||||
<CircularProgress />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showHow} onClose={() => setShowHow(false)}>
|
||||
<DialogTitle>How does this work?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stepper activeStep={-1} orientation="vertical">
|
||||
<Step expanded>
|
||||
<StepLabel>Identify Your Instance</StepLabel>
|
||||
<StepContent>
|
||||
A simple text box, we will verify it for you
|
||||
</StepContent>
|
||||
</Step>
|
||||
<Step expanded>
|
||||
<StepLabel>Identify Your User</StepLabel>
|
||||
<StepContent>
|
||||
Inputing the username you go by on your instance
|
||||
</StepContent>
|
||||
</Step>
|
||||
<Step expanded>
|
||||
<StepLabel>Send/Receive a Code</StepLabel>
|
||||
<StepContent>
|
||||
Sending or receiving depends on the instance's software
|
||||
</StepContent>
|
||||
</Step>
|
||||
</Stepper>
|
||||
<Alert color="info" sx={{ mt: 1 }}>
|
||||
No passwords will be entered
|
||||
</Alert>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
sx={{ textTransform: "unset" }}
|
||||
onClick={() => setShowHow(false)}
|
||||
>
|
||||
Ok, sounds good!
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,97 @@
|
|||
import {
|
||||
Alert,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Link,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { PageWrapper } from "../PageWrapper";
|
||||
import { LoadingButton } from "@mui/lab";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export const LogoutPage = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [state, setState] = useState<
|
||||
"logged-in" | "now-logged-out" | "never-logged-in"
|
||||
>();
|
||||
|
||||
useEffect(() => {
|
||||
fetch(import.meta.env.VITE_APP_ROOT + "/api/v1/whoami", {
|
||||
credentials: "include",
|
||||
})
|
||||
.then((a) => a.json())
|
||||
.then((data) => {
|
||||
if ("error" in data) {
|
||||
setState("never-logged-in");
|
||||
} else {
|
||||
setState("logged-in");
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const doLogout = () => {
|
||||
setLoading(true);
|
||||
|
||||
fetch(import.meta.env.VITE_APP_ROOT + "/api/v1/logout", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
})
|
||||
.then((a) => a.json())
|
||||
.then((data) => {
|
||||
if (data.success) {
|
||||
setState("now-logged-out");
|
||||
} else {
|
||||
alert("Error: " + data.error);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<Typography variant="h5" component="div" sx={{ mb: 1 }}>
|
||||
Logout
|
||||
</Typography>
|
||||
{state === undefined && (
|
||||
<Box
|
||||
sx={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
{state === "never-logged-in" && (
|
||||
<Alert severity="info">
|
||||
<b>You are not logged in</b>
|
||||
</Alert>
|
||||
)}
|
||||
{state === "now-logged-out" && (
|
||||
<Alert severity="success">
|
||||
<b>You are now logged out!</b>
|
||||
</Alert>
|
||||
)}
|
||||
{state === "logged-in" && (
|
||||
<>
|
||||
<Typography>Are you sure you want to logout?</Typography>
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
onClick={doLogout}
|
||||
variant="contained"
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Logout
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Link href="/">Go Home</Link>
|
||||
</PageWrapper>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,21 @@
|
|||
import { Box, Card, CardContent } from "@mui/material";
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
export const PageWrapper = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
p: 5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: "min(400px,90%)" }}>
|
||||
<Card>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,31 @@
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const api = async <T = any>(
|
||||
endpoint: string,
|
||||
method: "GET" | "POST" = "GET",
|
||||
body?: unknown
|
||||
): Promise<{ status: number; data: T }> => {
|
||||
const API_HOST = import.meta.env.VITE_APP_ROOT || "";
|
||||
|
||||
const req = await fetch(API_HOST + endpoint, {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
...(body ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let data: any;
|
||||
|
||||
try {
|
||||
data = await req.json();
|
||||
} catch (e) {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
return {
|
||||
status: req.status,
|
||||
data,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,38 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
import { CssBaseline, ThemeProvider, createTheme } from "@mui/material";
|
||||
import { RouterProvider, createBrowserRouter } from "react-router-dom";
|
||||
import { LoginPage } from "./Login/Login.tsx";
|
||||
import { InteractionPage } from "./Interaction/InteractionPage.tsx";
|
||||
import { LogoutPage } from "./Logout/Logout.tsx";
|
||||
|
||||
const theme = createTheme({ palette: { mode: "dark" } });
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <App />,
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: "/logout",
|
||||
element: <LogoutPage />,
|
||||
},
|
||||
{
|
||||
path: "/interaction/:id",
|
||||
element: <InteractionPage />,
|
||||
},
|
||||
]);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<RouterProvider router={router} />
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
|
@ -0,0 +1,23 @@
|
|||
export interface IOIDC_Interaction {
|
||||
jti: string;
|
||||
returnTo: string;
|
||||
prompt: {
|
||||
name: string;
|
||||
details: any;
|
||||
reasons: string[];
|
||||
};
|
||||
params: {
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IOIDC_Client {
|
||||
clientId: string;
|
||||
redirectUris: string[];
|
||||
clientName?: string;
|
||||
clientUri?: string;
|
||||
logoUri?: string;
|
||||
policyUri?: string;
|
||||
tosUri?: string;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "fediverse-auth",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"repository": "ssh://git@git.sc07.company:2424/sc07/fediverse-auth.git",
|
||||
"author": "Grant <grant@sc07.company>",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"frontend",
|
||||
"backend"
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue