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