-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -32,6 +47,8 @@
\ No newline at end of file
diff --git a/pages/main.ts b/pages/main.ts
index d48aedce..6c9302be 100644
--- a/pages/main.ts
+++ b/pages/main.ts
@@ -1,5 +1,6 @@
import { createApp } from "vue";
import "./style.css";
+import "virtual:uno.css";
import { createRouter, createWebHistory } from "vue-router";
import Login from "./login.vue";
import App from "./App.vue";
@@ -8,7 +9,7 @@ const Home = { template: "
Home
" };
const routes = [
{ path: "/", component: Home },
- { path: "/oauth/login", component: Login },
+ { path: "/oauth/authorize", component: Login },
];
const router = createRouter({
diff --git a/pages/uno.css b/pages/uno.css
deleted file mode 100644
index 4e3b1a12..00000000
--- a/pages/uno.css
+++ /dev/null
@@ -1,155 +0,0 @@
-/* layer: preflights */
-*,::before,::after{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgba(0,0,0,0);--un-ring-shadow:0 0 rgba(0,0,0,0);--un-shadow-inset: ;--un-shadow:0 0 rgba(0,0,0,0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}::backdrop{--un-rotate:0;--un-rotate-x:0;--un-rotate-y:0;--un-rotate-z:0;--un-scale-x:1;--un-scale-y:1;--un-scale-z:1;--un-skew-x:0;--un-skew-y:0;--un-translate-x:0;--un-translate-y:0;--un-translate-z:0;--un-pan-x: ;--un-pan-y: ;--un-pinch-zoom: ;--un-scroll-snap-strictness:proximity;--un-ordinal: ;--un-slashed-zero: ;--un-numeric-figure: ;--un-numeric-spacing: ;--un-numeric-fraction: ;--un-border-spacing-x:0;--un-border-spacing-y:0;--un-ring-offset-shadow:0 0 rgba(0,0,0,0);--un-ring-shadow:0 0 rgba(0,0,0,0);--un-shadow-inset: ;--un-shadow:0 0 rgba(0,0,0,0);--un-ring-inset: ;--un-ring-offset-width:0px;--un-ring-offset-color:#fff;--un-ring-width:0px;--un-ring-color:rgba(147,197,253,0.5);--un-blur: ;--un-brightness: ;--un-contrast: ;--un-drop-shadow: ;--un-grayscale: ;--un-hue-rotate: ;--un-invert: ;--un-saturate: ;--un-sepia: ;--un-backdrop-blur: ;--un-backdrop-brightness: ;--un-backdrop-contrast: ;--un-backdrop-grayscale: ;--un-backdrop-hue-rotate: ;--un-backdrop-invert: ;--un-backdrop-opacity: ;--un-backdrop-saturate: ;--un-backdrop-sepia: ;}
-[type='text'], [type='email'], [type='url'], [type='password'], [type='number'], [type='date'], [type='datetime-local'], [type='month'], [type='search'], [type='tel'], [type='time'], [type='week'], [multiple], textarea, select { appearance: none;
-background-color: #fff;
-border-color: #6b7280;
-border-width: 1px;
-border-radius: 0;
-padding-top: 0.5rem;
-padding-right: 0.75rem;
-padding-bottom: 0.5rem;
-padding-left: 0.75rem;
-font-size: 1rem;
-line-height: 1.5rem;
---un-shadow: 0 0 #0000; }
-[type='text']:focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus { outline: 2px solid transparent;
-outline-offset: 2px;
---un-ring-inset: var(--un-empty,/*!*/ /*!*/);
---un-ring-offset-width: 0px;
---un-ring-offset-color: #fff;
---un-ring-color: #2563eb;
---un-ring-offset-shadow: var(--un-ring-inset) 0 0 0 var(--un-ring-offset-width) var(--un-ring-offset-color);
---un-ring-shadow: var(--un-ring-inset) 0 0 0 calc(1px + var(--un-ring-offset-width)) var(--un-ring-color);
-box-shadow: var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);
-border-color: #2563eb; }
-input::placeholder, textarea::placeholder { color: #6b7280;
-opacity: 1; }
-::-webkit-datetime-edit-fields-wrapper { padding: 0; }
-::-webkit-date-and-time-value { min-height: 1.5em; }
-::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { padding-top: 0;
-padding-bottom: 0; }
-select { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
-background-position: right 0.5rem center;
-background-repeat: no-repeat;
-background-size: 1.5em 1.5em;
-padding-right: 2.5rem;
-print-color-adjust: exact; }
-[multiple] { background-image: initial;
-background-position: initial;
-background-repeat: unset;
-background-size: initial;
-padding-right: 0.75rem;
-print-color-adjust: unset; }
-[type='checkbox'], [type='radio'] { appearance: none;
-padding: 0;
-print-color-adjust: exact;
-display: inline-block;
-vertical-align: middle;
-background-origin: border-box;
-user-select: none;
-flex-shrink: 0;
-height: 1rem;
-width: 1rem;
-color: #2563eb;
-background-color: #fff;
-border-color: #6b7280;
-border-width: 1px;
---un-shadow: 0 0 #0000; }
-[type='checkbox'] { border-radius: 0; }
-[type='radio'] { border-radius: 100%; }
-[type='checkbox']:focus, [type='radio']:focus { outline: 2px solid transparent;
-outline-offset: 2px;
---un-ring-inset: var(--un-empty,/*!*/ /*!*/);
---un-ring-offset-width: 2px;
---un-ring-offset-color: #fff;
---un-ring-color: #2563eb;
---un-ring-offset-shadow: var(--un-ring-inset) 0 0 0 var(--un-ring-offset-width) var(--un-ring-offset-color);
---un-ring-shadow: var(--un-ring-inset) 0 0 0 calc(2px + var(--un-ring-offset-width)) var(--un-ring-color);
-box-shadow: var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow); }
-[type='checkbox']:checked, [type='radio']:checked { border-color: transparent;
-background-color: currentColor;
-background-size: 100% 100%;
-background-position: center;
-background-repeat: no-repeat; }
-[type='checkbox']:checked { background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); }
-[type='radio']:checked { background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); }
-[type='checkbox']:checked:hover, [type='checkbox']:checked:focus, [type='radio']:checked:hover, [type='radio']:checked:focus { border-color: transparent;
-background-color: currentColor; }
-[type='checkbox']:indeterminate { background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");
-border-color: transparent;
-background-color: currentColor;
-background-size: 100% 100%;
-background-position: center;
-background-repeat: no-repeat; }
-[type='checkbox']:indeterminate:hover, [type='checkbox']:indeterminate:focus { border-color: transparent;
-background-color: currentColor; }
-[type='file'] { background: unset;
-border-color: inherit;
-border-width: 0;
-border-radius: 0;
-padding: 0;
-font-size: unset;
-line-height: inherit; }
-[type='file']:focus { outline: 1px solid ButtonText , 1px auto -webkit-focus-ring-color; }
-/* layer: default */
-.visible{visibility:visible;}
-.relative{position:relative;}
-.mt-10{margin-top:2.5rem;}
-.mt-2{margin-top:0.5rem;}
-.block{display:block;}
-.contents{display:contents;}
-.list-item{display:list-item;}
-.hidden{display:none;}
-.h6{height:1.5rem;}
-.min-h-screen{min-height:100vh;}
-.w-full{width:100%;}
-.flex{display:flex;}
-.flex-col{flex-direction:column;}
-.table{display:table;}
-.border-collapse{border-collapse:collapse;}
-.transform{transform:translateX(var(--un-translate-x)) translateY(var(--un-translate-y)) translateZ(var(--un-translate-z)) rotate(var(--un-rotate)) rotateX(var(--un-rotate-x)) rotateY(var(--un-rotate-y)) rotateZ(var(--un-rotate-z)) skewX(var(--un-skew-x)) skewY(var(--un-skew-y)) scaleX(var(--un-scale-x)) scaleY(var(--un-scale-y)) scaleZ(var(--un-scale-z));}
-.resize{resize:both;}
-.items-center{align-items:center;}
-.justify-center{justify-content:center;}
-.justify-between{justify-content:space-between;}
-.space-y-6>:not([hidden])~:not([hidden]){--un-space-y-reverse:0;margin-top:calc(1.5rem * calc(1 - var(--un-space-y-reverse)));margin-bottom:calc(1.5rem * var(--un-space-y-reverse));}
-.border{border-width:1px;}
-.border-0{border-width:0;}
-.rounded-md{border-radius:0.375rem;}
-.\!bg-indigo-600{--un-bg-opacity:1 !important;background-color:rgba(79,70,229,var(--un-bg-opacity)) !important;}
-.hover\:\!bg-indigo-500:hover{--un-bg-opacity:1 !important;background-color:rgba(99,102,241,var(--un-bg-opacity)) !important;}
-.px-3{padding-left:0.75rem;padding-right:0.75rem;}
-.px-6{padding-left:1.5rem;padding-right:1.5rem;}
-.py-1\.5{padding-top:0.375rem;padding-bottom:0.375rem;}
-.py-12{padding-top:3rem;padding-bottom:3rem;}
-.text-sm{font-size:0.875rem;line-height:1.25rem;}
-.font-medium{font-weight:500;}
-.font-semibold{font-weight:600;}
-.leading-6{line-height:1.5rem;}
-.text-gray-900{--un-text-opacity:1;color:rgba(17,24,39,var(--un-text-opacity));}
-.text-white{--un-text-opacity:1;color:rgba(255,255,255,var(--un-text-opacity));}
-.placeholder\:text-gray-400::placeholder{--un-text-opacity:1;color:rgba(156,163,175,var(--un-text-opacity));}
-.underline{text-decoration-line:underline;}
-.tab{-moz-tab-size:4;-o-tab-size:4;tab-size:4;}
-.shadow-sm{--un-shadow:var(--un-shadow-inset) 0 1px 2px 0 var(--un-shadow-color, rgba(0,0,0,0.05));box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}
-.focus-visible\:outline-2:focus-visible{outline-width:2px;}
-.focus-visible\:outline-indigo-600:focus-visible{--un-outline-color-opacity:1;outline-color:rgba(79,70,229,var(--un-outline-color-opacity));}
-.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px;}
-.outline{outline-style:solid;}
-.focus-visible\:outline:focus-visible{outline-style:solid;}
-.ring-1{--un-ring-width:1px;--un-ring-offset-shadow:var(--un-ring-inset) 0 0 0 var(--un-ring-offset-width) var(--un-ring-offset-color);--un-ring-shadow:var(--un-ring-inset) 0 0 0 calc(var(--un-ring-width) + var(--un-ring-offset-width)) var(--un-ring-color);box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}
-.focus\:ring-2:focus{--un-ring-width:2px;--un-ring-offset-shadow:var(--un-ring-inset) 0 0 0 var(--un-ring-offset-width) var(--un-ring-offset-color);--un-ring-shadow:var(--un-ring-inset) 0 0 0 calc(var(--un-ring-width) + var(--un-ring-offset-width)) var(--un-ring-color);box-shadow:var(--un-ring-offset-shadow), var(--un-ring-shadow), var(--un-shadow);}
-.ring-gray-300{--un-ring-opacity:1;--un-ring-color:rgba(209,213,219,var(--un-ring-opacity));}
-.focus\:ring-indigo-600:focus{--un-ring-opacity:1;--un-ring-color:rgba(79,70,229,var(--un-ring-opacity));}
-.ring-inset{--un-ring-inset:inset;}
-.focus\:ring-inset:focus{--un-ring-inset:inset;}
-@media (min-width: 640px){
-.sm\:mx-auto{margin-left:auto;margin-right:auto;}
-.sm\:max-w-sm{max-width:24rem;}
-.sm\:w-full{width:100%;}
-.sm\:text-sm{font-size:0.875rem;line-height:1.25rem;}
-.sm\:leading-6{line-height:1.5rem;}
-}
-@media (min-width: 1024px){
-.lg\:px-8{padding-left:2rem;padding-right:2rem;}
-}
\ No newline at end of file
diff --git a/pages/vite.config.ts b/pages/vite.config.ts
index d94dfe72..8bd4b8dd 100644
--- a/pages/vite.config.ts
+++ b/pages/vite.config.ts
@@ -23,7 +23,7 @@ export default defineConfig({
},
plugins: [
UnoCSS({
- mode: "vue-scoped",
+ mode: "global",
}),
vue(),
],
diff --git a/prisma.ts b/prisma.ts
index 82d8c1d5..a0ee9c59 100644
--- a/prisma.ts
+++ b/prisma.ts
@@ -2,16 +2,8 @@
import { getConfig } from "@config";
-const args = process.argv.slice(2);
const config = getConfig();
-const { stdout } = Bun.spawn(["bunx", "prisma", ...args], {
- env: {
- ...process.env,
- DATABASE_URL: `postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}`,
- },
-});
-
-// Show stdout
-const text = await new Response(stdout).text();
-console.log(text);
+process.stdout.write(
+ `postgresql://${config.database.username}:${config.database.password}@${config.database.host}:${config.database.port}/${config.database.database}`
+);
diff --git a/prisma/migrations/20231206215508_add_openid/migration.sql b/prisma/migrations/20231206215508_add_openid/migration.sql
new file mode 100644
index 00000000..32b92f4d
--- /dev/null
+++ b/prisma/migrations/20231206215508_add_openid/migration.sql
@@ -0,0 +1,20 @@
+-- CreateTable
+CREATE TABLE "OpenIdLoginFlow" (
+ "id" UUID NOT NULL DEFAULT uuid_generate_v7(),
+ "codeVerifier" TEXT NOT NULL,
+
+ CONSTRAINT "OpenIdLoginFlow_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "OpenIdAccount" (
+ "id" UUID NOT NULL DEFAULT uuid_generate_v7(),
+ "userId" UUID,
+ "serverId" TEXT NOT NULL,
+ "issuerId" TEXT NOT NULL,
+
+ CONSTRAINT "OpenIdAccount_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "OpenIdAccount" ADD CONSTRAINT "OpenIdAccount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index bbba2240..e5539dfa 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -139,6 +139,11 @@ model Token {
applicationId String? @db.Uuid
}
+model OpenIdLoginFlow {
+ id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
+ codeVerifier String
+}
+
model Attachment {
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
url String
@@ -170,36 +175,45 @@ model Notification {
}
model User {
- id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
- uri String @unique
- username String @unique
+ id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
+ uri String @unique
+ username String @unique
displayName String
password String? // Nullable
- email String? @unique // Nullable
- note String @default("")
- isAdmin Boolean @default(false)
+ email String? @unique // Nullable
+ note String @default("")
+ isAdmin Boolean @default(false)
endpoints Json? // Nullable
source Json
avatar String
header String
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- isBot Boolean @default(false)
- isLocked Boolean @default(false)
- isDiscoverable Boolean @default(false)
- sanctions String[] @default([])
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ isBot Boolean @default(false)
+ isLocked Boolean @default(false)
+ isDiscoverable Boolean @default(false)
+ sanctions String[] @default([])
publicKey String
privateKey String? // Nullable
- relationships Relationship[] @relation("OwnerToRelationship") // One to many relation with Relationship
- relationshipSubjects Relationship[] @relation("SubjectToRelationship") // One to many relation with Relationship
- instance Instance? @relation(fields: [instanceId], references: [id], onDelete: Cascade) // Many to one relation with Instance
- instanceId String? @db.Uuid
- pinnedNotes Status[] @relation("UserPinnedNotes") // Many to many relation with Status
+ relationships Relationship[] @relation("OwnerToRelationship") // One to many relation with Relationship
+ relationshipSubjects Relationship[] @relation("SubjectToRelationship") // One to many relation with Relationship
+ instance Instance? @relation(fields: [instanceId], references: [id], onDelete: Cascade) // Many to one relation with Instance
+ instanceId String? @db.Uuid
+ pinnedNotes Status[] @relation("UserPinnedNotes") // Many to many relation with Status
emojis Emoji[] // Many to many relation with Emoji
- statuses Status[] @relation("UserStatuses") // One to many relation with Status
+ statuses Status[] @relation("UserStatuses") // One to many relation with Status
tokens Token[] // One to many relation with Token
- likes Like[] @relation("UserLiked") // One to many relation with Like
+ likes Like[] @relation("UserLiked") // One to many relation with Like
statusesMentioned Status[] // Many to many relation with Status
notifications Notification[] // One to many relation with Notification
- notified Notification[] @relation("NotificationToNotified") // One to many relation with Notification
+ notified Notification[] @relation("NotificationToNotified") // One to many relation with Notification
+ linkedOpenIdAccounts OpenIdAccount[] // One to many relation with OpenIdAccount
+}
+
+model OpenIdAccount {
+ id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
+ User User? @relation(fields: [userId], references: [id])
+ userId String? @db.Uuid
+ serverId String // ID on the authorization server
+ issuerId String
}
diff --git a/server/api/auth/login/index.ts b/server/api/auth/login/index.ts
index 8a3b9b24..c8d6bc9a 100644
--- a/server/api/auth/login/index.ts
+++ b/server/api/auth/login/index.ts
@@ -1,5 +1,4 @@
import { applyConfig } from "@api";
-import { errorResponse } from "@response";
import type { MatchedRoute } from "bun";
import { randomBytes } from "crypto";
import { client } from "~database/datasource";
@@ -38,11 +37,21 @@ export default async (
const email = formData.get("email")?.toString() || null;
const password = formData.get("password")?.toString() || null;
+ const redirectToLogin = (error: string) =>
+ Response.redirect(
+ `/oauth/authorize?` +
+ new URLSearchParams({
+ ...matchedRoute.query,
+ error: encodeURIComponent(error),
+ }).toString(),
+ 302
+ );
+
if (response_type !== "code")
- return errorResponse("Invalid response type (try 'code')", 400);
+ return redirectToLogin("Invalid response_type");
if (!email || !password)
- return errorResponse("Missing username or password", 400);
+ return redirectToLogin("Invalid username or password");
// Get user
const user = await client.user.findFirst({
@@ -53,7 +62,7 @@ export default async (
});
if (!user || !(await Bun.password.verify(password, user.password || "")))
- return errorResponse("Invalid username or password", 401);
+ return redirectToLogin("Invalid username or password");
// Get application
const application = await client.application.findFirst({
@@ -62,7 +71,7 @@ export default async (
},
});
- if (!application) return errorResponse("Invalid client_id", 404);
+ if (!application) return redirectToLogin("Invalid client_id");
const code = randomBytes(32).toString("hex");
diff --git a/server/api/oauth/authorize-external/index.ts b/server/api/oauth/authorize-external/index.ts
new file mode 100644
index 00000000..a4562e81
--- /dev/null
+++ b/server/api/oauth/authorize-external/index.ts
@@ -0,0 +1,84 @@
+import { applyConfig } from "@api";
+import { getConfig } from "@config";
+import { oauthRedirectUri } from "@constants";
+import type { MatchedRoute } from "bun";
+import {
+ calculatePKCECodeChallenge,
+ discoveryRequest,
+ processDiscoveryResponse,
+} from "oauth4webapi";
+
+export const meta = applyConfig({
+ allowedMethods: ["GET"],
+ auth: {
+ required: false,
+ },
+ ratelimits: {
+ duration: 60,
+ max: 20,
+ },
+ route: "/oauth/authorize-external",
+});
+
+/**
+ * Redirects the user to the external OAuth provider
+ */
+export default async (
+ req: Request,
+ matchedRoute: MatchedRoute
+ // eslint-disable-next-line @typescript-eslint/require-await
+): Promise
=> {
+ const redirectToLogin = (error: string) =>
+ Response.redirect(
+ `/oauth/authorize?` +
+ new URLSearchParams({
+ ...matchedRoute.query,
+ error: encodeURIComponent(error),
+ }).toString(),
+ 302
+ );
+
+ const issuerId = matchedRoute.query.issuer;
+
+ // This is the Lysand client's client_id, not the external OAuth provider's client_id
+ const clientId = matchedRoute.query.clientId;
+
+ if (!clientId || clientId === "undefined") {
+ return redirectToLogin("Missing client_id");
+ }
+
+ const config = getConfig();
+
+ const issuer = config.oidc.providers.find(
+ provider => provider.id === issuerId
+ );
+
+ if (!issuer) {
+ return redirectToLogin("Invalid issuer");
+ }
+
+ const issuerUrl = new URL(issuer.url);
+
+ const authServer = await discoveryRequest(issuerUrl, {
+ algorithm: "oidc",
+ }).then(res => processDiscoveryResponse(issuerUrl, res));
+
+ const codeVerifier = "tempString";
+ const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
+
+ return Response.redirect(
+ authServer.authorization_endpoint +
+ "?" +
+ new URLSearchParams({
+ client_id: issuer.client_id,
+ redirect_uri:
+ oauthRedirectUri(issuerId) + `?clientId=${clientId}`,
+ response_type: "code",
+ scope: "openid profile email",
+ // PKCE
+ code_challenge_method: "S256",
+ code_challenge: codeChallenge,
+ }).toString(),
+ 302
+ );
+};
diff --git a/server/api/oauth/authorize/index.ts b/server/api/oauth/authorize/index.ts
deleted file mode 100644
index 219437d6..00000000
--- a/server/api/oauth/authorize/index.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { applyConfig } from "@api";
-import type { MatchedRoute } from "bun";
-
-export const meta = applyConfig({
- allowedMethods: ["GET"],
- auth: {
- required: false,
- },
- ratelimits: {
- duration: 60,
- max: 20,
- },
- route: "/oauth/authorize",
-});
-
-/**
- * Returns an HTML login form
- */
-export default async (
- req: Request,
- matchedRoute: MatchedRoute
-): Promise => {
- const html = Bun.file("./pages/login.html");
- const css = Bun.file("./pages/uno.css");
- return new Response(
- (await html.text())
- .replace(
- "{{URL}}",
- `/auth/login?redirect_uri=${matchedRoute.query.redirect_uri}&response_type=${matchedRoute.query.response_type}&client_id=${matchedRoute.query.client_id}&scope=${matchedRoute.query.scope}`
- )
- .replace("{{STYLES}}", ``),
- {
- headers: {
- "Content-Type": "text/html",
- },
- }
- );
-};
diff --git a/server/api/oauth/callback/[issuer]/index.ts b/server/api/oauth/callback/[issuer]/index.ts
new file mode 100644
index 00000000..aca0bba2
--- /dev/null
+++ b/server/api/oauth/callback/[issuer]/index.ts
@@ -0,0 +1,187 @@
+import { applyConfig } from "@api";
+import { getConfig } from "@config";
+import { oauthRedirectUri } from "@constants";
+import type { MatchedRoute } from "bun";
+import { randomBytes } from "crypto";
+import {
+ authorizationCodeGrantRequest,
+ discoveryRequest,
+ expectNoState,
+ isOAuth2Error,
+ processDiscoveryResponse,
+ validateAuthResponse,
+ userInfoRequest,
+ processAuthorizationCodeOpenIDResponse,
+ processUserInfoResponse,
+ getValidatedIdTokenClaims,
+} from "oauth4webapi";
+import { client } from "~database/datasource";
+import { TokenType } from "~database/entities/Token";
+
+export const meta = applyConfig({
+ allowedMethods: ["GET"],
+ auth: {
+ required: false,
+ },
+ ratelimits: {
+ duration: 60,
+ max: 20,
+ },
+ route: "/oauth/callback/:issuer",
+});
+
+/**
+ * Redirects the user to the external OAuth provider
+ */
+export default async (
+ req: Request,
+ matchedRoute: MatchedRoute
+): Promise => {
+ const redirectToLogin = (error: string) =>
+ Response.redirect(
+ `/oauth/authorize?` +
+ new URLSearchParams({
+ client_id: matchedRoute.query.clientId,
+ error: encodeURIComponent(error),
+ }).toString(),
+ 302
+ );
+
+ const currentUrl = new URL(req.url);
+
+ // Remove state query parameter from URL
+ currentUrl.searchParams.delete("state");
+ const issuerParam = matchedRoute.params.issuer;
+ // This is the Lysand client's client_id, not the external OAuth provider's client_id
+ const clientId = matchedRoute.query.clientId;
+
+ const config = getConfig();
+
+ const issuer = config.oidc.providers.find(
+ provider => provider.id === issuerParam
+ );
+
+ if (!issuer) {
+ return redirectToLogin("Invalid issuer");
+ }
+
+ const issuerUrl = new URL(issuer.url);
+
+ const authServer = await discoveryRequest(issuerUrl, {
+ algorithm: "oidc",
+ }).then(res => processDiscoveryResponse(issuerUrl, res));
+
+ const parameters = validateAuthResponse(
+ authServer,
+ {
+ client_id: issuer.client_id,
+ client_secret: issuer.client_secret,
+ },
+ currentUrl,
+ // Whether to expect state or not
+ expectNoState
+ );
+
+ if (isOAuth2Error(parameters)) {
+ return redirectToLogin(
+ parameters.error_description || parameters.error
+ );
+ }
+
+ const response = await authorizationCodeGrantRequest(
+ authServer,
+ {
+ client_id: issuer.client_id,
+ client_secret: issuer.client_secret,
+ },
+ parameters,
+ oauthRedirectUri(issuerParam) + `?clientId=${clientId}`,
+ "tempString"
+ );
+
+ const result = await processAuthorizationCodeOpenIDResponse(
+ authServer,
+ {
+ client_id: issuer.client_id,
+ client_secret: issuer.client_secret,
+ },
+ response
+ );
+
+ if (isOAuth2Error(result)) {
+ return redirectToLogin(result.error_description || result.error);
+ }
+
+ const { access_token } = result;
+
+ const claims = getValidatedIdTokenClaims(result);
+ const { sub } = claims;
+
+ // Validate `sub`
+ // Later, we'll use this to automatically set the user's data
+ await userInfoRequest(
+ authServer,
+ {
+ client_id: issuer.client_id,
+ client_secret: issuer.client_secret,
+ },
+ access_token
+ ).then(res =>
+ processUserInfoResponse(
+ authServer,
+ {
+ client_id: issuer.client_id,
+ client_secret: issuer.client_secret,
+ },
+ sub,
+ res
+ )
+ );
+
+ const user = await client.user.findFirst({
+ where: {
+ linkedOpenIdAccounts: {
+ some: {
+ serverId: sub,
+ issuerId: issuer.id,
+ },
+ },
+ },
+ });
+
+ if (!user) {
+ return redirectToLogin("No user found with that account");
+ }
+
+ const application = await client.application.findFirst({
+ where: {
+ client_id: clientId,
+ },
+ });
+
+ if (!application) return redirectToLogin("Invalid client_id");
+
+ const code = randomBytes(32).toString("hex");
+
+ await client.application.update({
+ where: { id: application.id },
+ data: {
+ tokens: {
+ create: {
+ access_token: randomBytes(64).toString("base64url"),
+ code: code,
+ scope: application.scopes,
+ token_type: TokenType.BEARER,
+ user: {
+ connect: {
+ id: user.id,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ // Redirect back to application
+ return Response.redirect(`${application.redirect_uris}?code=${code}`, 302);
+};
diff --git a/server/api/oauth/providers/index.ts b/server/api/oauth/providers/index.ts
new file mode 100644
index 00000000..86061716
--- /dev/null
+++ b/server/api/oauth/providers/index.ts
@@ -0,0 +1,31 @@
+import { applyConfig } from "@api";
+import { getConfig } from "@config";
+import { jsonResponse } from "@response";
+
+export const meta = applyConfig({
+ allowedMethods: ["GET"],
+ auth: {
+ required: false,
+ },
+ ratelimits: {
+ duration: 60,
+ max: 10,
+ },
+ route: "/oauth/providers",
+});
+
+/**
+ * Lists available OAuth providers
+ */
+// eslint-disable-next-line @typescript-eslint/require-await
+export default async (): Promise => {
+ const config = getConfig();
+
+ return jsonResponse(
+ config.oidc.providers.map(p => ({
+ name: p.name,
+ icon: p.icon,
+ id: p.id,
+ }))
+ );
+};
diff --git a/temp/flow_0exywktgkcq6wjwa691mat b/temp/flow_0exywktgkcq6wjwa691mat
new file mode 100644
index 00000000..e4abcccf
--- /dev/null
+++ b/temp/flow_0exywktgkcq6wjwa691mat
@@ -0,0 +1 @@
+FMFZr5UtmZkKH6vPzSZijJbpF3ySKLaocHNtHghJJdw
\ No newline at end of file
diff --git a/temp/flow_2c0ojqmxbidvqt6g964fnn b/temp/flow_2c0ojqmxbidvqt6g964fnn
new file mode 100644
index 00000000..d3029db3
--- /dev/null
+++ b/temp/flow_2c0ojqmxbidvqt6g964fnn
@@ -0,0 +1 @@
+dOeAMvn88EbHZAYx0EJNbrw97XhzbxZXXYOt7lF8zzQ
\ No newline at end of file
diff --git a/temp/flow_366pnjfy46z7a6ijrv2s2e b/temp/flow_366pnjfy46z7a6ijrv2s2e
new file mode 100644
index 00000000..0c6beb5a
--- /dev/null
+++ b/temp/flow_366pnjfy46z7a6ijrv2s2e
@@ -0,0 +1 @@
+3QEq5HYhdzRX3Rozulasmj2XAYlWiX9zwIJY2JDn4uw
\ No newline at end of file
diff --git a/temp/flow_9gk2f91f7rpr4zz1lza4n8 b/temp/flow_9gk2f91f7rpr4zz1lza4n8
new file mode 100644
index 00000000..267694b7
--- /dev/null
+++ b/temp/flow_9gk2f91f7rpr4zz1lza4n8
@@ -0,0 +1 @@
+OJLAurMR0_MdWhsYAnGigFjClLrAaBwEDKIr5cddeuI
\ No newline at end of file
diff --git a/temp/flow_9xab9zbynrche1s5oqpko b/temp/flow_9xab9zbynrche1s5oqpko
new file mode 100644
index 00000000..e3d64d12
--- /dev/null
+++ b/temp/flow_9xab9zbynrche1s5oqpko
@@ -0,0 +1 @@
+S_yh9zW_PpgN5r4ngIESd5GRarZfMIBUoaGOJZhjpc8
\ No newline at end of file
diff --git a/temp/flow_adnvd9wrsykciwdbfz9da8 b/temp/flow_adnvd9wrsykciwdbfz9da8
new file mode 100644
index 00000000..e4c2a505
--- /dev/null
+++ b/temp/flow_adnvd9wrsykciwdbfz9da8
@@ -0,0 +1 @@
+uWPueKrIXJdtgVGEQ9n8peXmz0B6qb1ohEmOqYgCdws
\ No newline at end of file
diff --git a/temp/flow_b7w5jcwll3tee0l4tq9ejw b/temp/flow_b7w5jcwll3tee0l4tq9ejw
new file mode 100644
index 00000000..f6c30224
--- /dev/null
+++ b/temp/flow_b7w5jcwll3tee0l4tq9ejw
@@ -0,0 +1 @@
+HfOXX-niktBwwUJOLP6sq_IdbIWkVPt1BqWBbKKd_0A
\ No newline at end of file
diff --git a/temp/flow_bcw2mcd2l3pbfsvwp4t1tp b/temp/flow_bcw2mcd2l3pbfsvwp4t1tp
new file mode 100644
index 00000000..f3c80391
--- /dev/null
+++ b/temp/flow_bcw2mcd2l3pbfsvwp4t1tp
@@ -0,0 +1 @@
+rOUTRmSFMJMPS0tUtqklfJo-_VAxMAFKCN8uprb0e64
\ No newline at end of file
diff --git a/temp/flow_be9y4se653i4ks1qdvhiu5 b/temp/flow_be9y4se653i4ks1qdvhiu5
new file mode 100644
index 00000000..f1c53d11
--- /dev/null
+++ b/temp/flow_be9y4se653i4ks1qdvhiu5
@@ -0,0 +1 @@
+EsXqSioUt0FTlNfLcVIAPs9U3pS69nO1NbabgpPkyR0
\ No newline at end of file
diff --git a/temp/flow_bwluoig4sco7i5jrau0u b/temp/flow_bwluoig4sco7i5jrau0u
new file mode 100644
index 00000000..dea57952
--- /dev/null
+++ b/temp/flow_bwluoig4sco7i5jrau0u
@@ -0,0 +1 @@
+DPAF37_dnsfE2YZNDkvqBkFXs745JQ4I7KDftNEK5kM
\ No newline at end of file
diff --git a/temp/flow_ckkbeibg93nohnk87ljuk b/temp/flow_ckkbeibg93nohnk87ljuk
new file mode 100644
index 00000000..7d40b37e
--- /dev/null
+++ b/temp/flow_ckkbeibg93nohnk87ljuk
@@ -0,0 +1 @@
+8PsIJrWP09gJqW_OSLCSygXlPqaiCPryBk-VR8mT2rA
\ No newline at end of file
diff --git a/temp/flow_ggo4fp8ui1ejwinui77ycf b/temp/flow_ggo4fp8ui1ejwinui77ycf
new file mode 100644
index 00000000..c2035ddb
--- /dev/null
+++ b/temp/flow_ggo4fp8ui1ejwinui77ycf
@@ -0,0 +1 @@
+lYOBZxX217uXRwm85z7GNKsc7TViRZ9At3yUIGzkkPM
\ No newline at end of file
diff --git a/temp/flow_jp0vw18g9qe63tucqixezt b/temp/flow_jp0vw18g9qe63tucqixezt
new file mode 100644
index 00000000..6b546b33
--- /dev/null
+++ b/temp/flow_jp0vw18g9qe63tucqixezt
@@ -0,0 +1 @@
+TaOooO3g0FyrLrwTwJG45AkS7mLT0O1btt1P2Vr1dEQ
\ No newline at end of file
diff --git a/temp/flow_llg9kgdkbdmy8m7e023lm b/temp/flow_llg9kgdkbdmy8m7e023lm
new file mode 100644
index 00000000..039115a9
--- /dev/null
+++ b/temp/flow_llg9kgdkbdmy8m7e023lm
@@ -0,0 +1 @@
+BD9We2MPmCytIKlsH7Wug8bmW9_lU-Xrx_-c-qhpsEI
\ No newline at end of file
diff --git a/temp/flow_lp0v28msillw76xmk83sx b/temp/flow_lp0v28msillw76xmk83sx
new file mode 100644
index 00000000..9ad9ed09
--- /dev/null
+++ b/temp/flow_lp0v28msillw76xmk83sx
@@ -0,0 +1 @@
+OlX8iM3rrAro8J_Ncb4OvN6tLKuyfjLMEOG_xJkbeHU
\ No newline at end of file
diff --git a/temp/flow_lpmjxmln4o2axlfu9kb0n b/temp/flow_lpmjxmln4o2axlfu9kb0n
new file mode 100644
index 00000000..bb0401fe
--- /dev/null
+++ b/temp/flow_lpmjxmln4o2axlfu9kb0n
@@ -0,0 +1 @@
+VMalTqVPTIUY-ynsIQJO4jG16KyxkMhsQgoKW3doOHo
\ No newline at end of file
diff --git a/temp/flow_nreir9hwdpjnylbl79ydl b/temp/flow_nreir9hwdpjnylbl79ydl
new file mode 100644
index 00000000..3b9e181c
--- /dev/null
+++ b/temp/flow_nreir9hwdpjnylbl79ydl
@@ -0,0 +1 @@
+tIXKI7IDrAikA_SbBTW6XqBQfR2uuWAg9WQYpiZ5DE0
\ No newline at end of file
diff --git a/temp/flow_rx8dih8r80gzhlosl1glw b/temp/flow_rx8dih8r80gzhlosl1glw
new file mode 100644
index 00000000..0c1c0d24
--- /dev/null
+++ b/temp/flow_rx8dih8r80gzhlosl1glw
@@ -0,0 +1 @@
+LdpsUh6F78GzSXz5zfZUW2ZaUbjoCC-zGrCjnz4gtDU
\ No newline at end of file
diff --git a/temp/flow_thi2f1athskd4cculln0n b/temp/flow_thi2f1athskd4cculln0n
new file mode 100644
index 00000000..58d988c2
--- /dev/null
+++ b/temp/flow_thi2f1athskd4cculln0n
@@ -0,0 +1 @@
+8Ch2fssgHUf4qTO4kHM-9yofisoalAufolmFkSOvo40
\ No newline at end of file
diff --git a/temp/flow_uiahlqg3uolo9x007xwhf b/temp/flow_uiahlqg3uolo9x007xwhf
new file mode 100644
index 00000000..69d9d66f
--- /dev/null
+++ b/temp/flow_uiahlqg3uolo9x007xwhf
@@ -0,0 +1 @@
+Yx9ZPZ11yaa64EApoZKLNNp0cIsp-GWoZmPk8Rg3yJA
\ No newline at end of file
diff --git a/temp/flow_vf6gxl8c4s6583pxpelsc b/temp/flow_vf6gxl8c4s6583pxpelsc
new file mode 100644
index 00000000..28b396e7
--- /dev/null
+++ b/temp/flow_vf6gxl8c4s6583pxpelsc
@@ -0,0 +1 @@
+0I35VFoN26g_y5ssRk0ilIjtmKD7s8oOVgqPCumt8Dw
\ No newline at end of file
diff --git a/temp/flow_vwgyv1bz05d69quclfqclo b/temp/flow_vwgyv1bz05d69quclfqclo
new file mode 100644
index 00000000..882da8b2
--- /dev/null
+++ b/temp/flow_vwgyv1bz05d69quclfqclo
@@ -0,0 +1 @@
+HoddM2bqLSELN9ICAn5Wx42dr0pLHrr-a48K_Zhes2w
\ No newline at end of file
diff --git a/temp/flow_w39k8p9uvnvcmj61wuzh b/temp/flow_w39k8p9uvnvcmj61wuzh
new file mode 100644
index 00000000..5603377e
--- /dev/null
+++ b/temp/flow_w39k8p9uvnvcmj61wuzh
@@ -0,0 +1 @@
+pdE7NC8nsUsxnywZah1akVtr324QI0zQVJOBabnyJSA
\ No newline at end of file
diff --git a/temp/flow_yjmm00zgy1ainhm9i6ggp b/temp/flow_yjmm00zgy1ainhm9i6ggp
new file mode 100644
index 00000000..baede7df
--- /dev/null
+++ b/temp/flow_yjmm00zgy1ainhm9i6ggp
@@ -0,0 +1 @@
+20WBny0Y2NnacGObQ91zCjRGQZdLhWzH8BaQfsQOga0
\ No newline at end of file
diff --git a/temp/flow_z26sq4gy8aiyfr835akz2d b/temp/flow_z26sq4gy8aiyfr835akz2d
new file mode 100644
index 00000000..47d9cf4c
--- /dev/null
+++ b/temp/flow_z26sq4gy8aiyfr835akz2d
@@ -0,0 +1 @@
+Bf8C4ChWmNROZkKLoMxVzQ9q6XMI253sje3d4IK_95Y
\ No newline at end of file
diff --git a/utils/config.ts b/utils/config.ts
index f8a9d12c..50b76eb1 100644
--- a/utils/config.ts
+++ b/utils/config.ts
@@ -32,6 +32,17 @@ export interface ConfigType {
enabled: boolean;
};
+ oidc: {
+ providers: {
+ name: string;
+ id: string;
+ url: string;
+ client_id: string;
+ client_secret: string;
+ icon: string;
+ }[];
+ };
+
http: {
base_url: string;
bind: string;
@@ -189,6 +200,9 @@ export const configDefaults: ConfigType = {
api_key: "",
enabled: false,
},
+ oidc: {
+ providers: [],
+ },
instance: {
banner: "",
description: "",
diff --git a/utils/constants.ts b/utils/constants.ts
new file mode 100644
index 00000000..91f729d7
--- /dev/null
+++ b/utils/constants.ts
@@ -0,0 +1,6 @@
+import { getConfig } from "@config";
+
+const config = getConfig();
+
+export const oauthRedirectUri = (issuer: string) =>
+ `${config.http.base_url}/oauth/callback/${issuer}`;
diff --git a/utils/oauth.ts b/utils/oauth.ts
index bc9110e1..f095242d 100644
--- a/utils/oauth.ts
+++ b/utils/oauth.ts
@@ -59,3 +59,5 @@ export const checkIfOauthIsValid = (
return false;
};
+
+export const oauthCodeVerifiers: Record = {};
diff --git a/utils/temp.ts b/utils/temp.ts
new file mode 100644
index 00000000..e31930cc
--- /dev/null
+++ b/utils/temp.ts
@@ -0,0 +1,20 @@
+import { join } from "path";
+import { exists, mkdir, writeFile, readFile } from "fs/promises";
+
+export const writeToTempDirectory = async (filename: string, data: string) => {
+ const tempDir = join(process.cwd(), "temp");
+ if (!(await exists(tempDir))) await mkdir(tempDir);
+
+ const tempFile = join(tempDir, filename);
+ await writeFile(tempFile, data);
+
+ return tempFile;
+};
+
+export const readFromTempDirectory = async (filename: string) => {
+ const tempDir = join(process.cwd(), "temp");
+ if (!(await exists(tempDir))) await mkdir(tempDir);
+
+ const tempFile = join(tempDir, filename);
+ return readFile(tempFile, "utf-8");
+};