initial commit

This commit is contained in:
Grant 2024-05-20 22:36:39 -06:00
commit b4f2bdba4a
59 changed files with 8125 additions and 0 deletions

12
.dockerignore Normal file
View File

@ -0,0 +1,12 @@
**/node_modules
build
data
**/dist
packages/build
Dockerfile
secrets
# dotfiles
.git*
.vscode
**/.env*

27
.gitignore vendored Normal file
View File

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

81
Dockerfile Normal file
View File

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

5
README.md Normal file
View File

@ -0,0 +1,5 @@
# fediverse-auth
Providing a central OpenID Connect service for Fediverse identification
Leverages OpenID Connect Auto Discovery & Dynamic Client Registration

4
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
# Keep environment variables out of version control
.env
dist

7
backend/README.md Normal file
View File

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

23
backend/example.env Normal file
View File

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

36
backend/package.json Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

55
backend/src/index.ts Normal file
View File

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

158
backend/src/lib/adapter.ts Normal file
View File

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

383
backend/src/lib/api.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

13
backend/src/lib/fetch.ts Normal file
View File

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

View File

@ -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: {},
};
};

145
backend/src/lib/nodeinfo.ts Normal file
View File

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

113
backend/src/lib/oidc.ts Normal file
View File

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

View File

@ -0,0 +1,3 @@
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();

57
backend/src/lib/utils.ts Normal file
View File

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

7
backend/src/types/environment.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
declare global {
namespace NodeJS {
interface ProcessEnv {
NODE_ENV: "development" | "production";
}
}
}

View File

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

26
backend/src/types/oidc.ts Normal file
View File

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

View File

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

15
backend/tsconfig.json Normal file
View File

@ -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/**/*"]
}

31
docker-compose.yml Normal file
View File

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

7
docker-start.sh Normal file
View File

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

43
example.env.local Normal file
View File

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

19
frontend/.eslintrc.cjs Normal file
View File

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

26
frontend/.gitignore vendored Normal file
View File

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

7
frontend/README.md Normal file
View File

@ -0,0 +1,7 @@
# fediverse-auth Frontend
Built with React & Material UI using Vite
## Development
Copy `example.env` to `.env` and edit the contents

3
frontend/example.env Normal file
View File

@ -0,0 +1,3 @@
# Development Use
# Specify the backend url
# VITE_APP_ROOT=http://localhost:3000

13
frontend/index.html Normal file
View File

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

38
frontend/package.json Normal file
View File

@ -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
frontend/src/App.css Normal file
View File

121
frontend/src/App.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
frontend/src/index.css Normal file
View File

31
frontend/src/lib/utils.ts Normal file
View File

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

38
frontend/src/main.tsx Normal file
View File

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

View File

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

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

25
frontend/tsconfig.json Normal file
View File

@ -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" }]
}

View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

7
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})

4615
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

12
package.json Normal file
View File

@ -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"
]
}