feat(api): Make Lysand a full OAuth2/OpenID Connect provider as well as still Mastodon compatible

This commit is contained in:
Jesse Wierzbinski 2024-04-17 22:42:12 -10:00
parent f9f4a99cb9
commit 5cb48b2f3b
No known key found for this signature in database
29 changed files with 8466 additions and 279 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -48,6 +48,10 @@ rules = [
"Don't post illegal content",
]
[oidc]
# Run Lysand with this value missing to generate a new key
jwt_key = ""
# Delete this section if you don't want to use custom OAuth providers
# This is an example configuration
# The provider MUST support OpenID Connect with .well-known discovery

View file

@ -19,7 +19,7 @@ export const objectToInboxRequest = async (
const privateKey = await crypto.subtle.importKey(
"pkcs8",
Uint8Array.from(atob(author.privateKey ?? ""), (c) => c.charCodeAt(0)),
Buffer.from(author.privateKey ?? "", "base64"),
"Ed25519",
false,
["sign"],

View file

@ -729,22 +729,13 @@ export const generateUserKeys = async () => {
"verify",
]);
const privateKey = btoa(
String.fromCharCode.apply(null, [
...new Uint8Array(
// jesus help me what do these letters mean
const privateKey = Buffer.from(
await crypto.subtle.exportKey("pkcs8", keys.privateKey),
),
]),
);
const publicKey = btoa(
String.fromCharCode(
...new Uint8Array(
// why is exporting a key so hard
).toString("base64");
const publicKey = Buffer.from(
await crypto.subtle.exportKey("spki", keys.publicKey),
),
),
);
).toString("base64");
// Add header, footer and newlines later on
// These keys are base64 encrypted

View file

@ -0,0 +1,3 @@
ALTER TABLE "Applications" RENAME COLUMN "redirect_uris" TO "redirect_uri";--> statement-breakpoint
ALTER TABLE "Tokens" ADD COLUMN "client_id" text NOT NULL DEFAULT '';--> statement-breakpoint
ALTER TABLE "Tokens" ADD COLUMN "redirect_uri" text NOT NULL DEFAULT '';

View file

@ -0,0 +1,2 @@
ALTER TABLE "Tokens" ALTER COLUMN "code" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "Tokens" ADD COLUMN "expires_at" timestamp(3);

View file

@ -0,0 +1,2 @@
ALTER TABLE "Tokens" ALTER COLUMN "client_id" SET DEFAULT '';
ALTER TABLE "Tokens" ALTER COLUMN "redirect_uri" SET DEFAULT '';

View file

@ -0,0 +1 @@
ALTER TABLE "Tokens" ADD COLUMN "id_token" text;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -113,6 +113,34 @@
"when": 1713399438164,
"tag": "0015_easy_mojo",
"breakpoints": true
},
{
"idx": 16,
"version": "5",
"when": 1713413369623,
"tag": "0016_keen_mindworm",
"breakpoints": true
},
{
"idx": 17,
"version": "5",
"when": 1713417089150,
"tag": "0017_dusty_black_knight",
"breakpoints": true
},
{
"idx": 18,
"version": "5",
"when": 1713418575392,
"tag": "0018_rapid_hairball",
"breakpoints": true
},
{
"idx": 19,
"version": "5",
"when": 1713421706451,
"tag": "0019_mushy_lorna_dane",
"breakpoints": true
}
]
}

View file

@ -187,7 +187,7 @@ export const Applications = pgTable(
clientId: text("client_id").notNull(),
secret: text("secret").notNull(),
scopes: text("scopes").notNull(),
redirectUris: text("redirect_uris").notNull(),
redirectUri: text("redirect_uri").notNull(),
},
(table) => {
return {
@ -206,10 +206,14 @@ export const Tokens = pgTable("Tokens", {
tokenType: text("token_type").notNull(),
scope: text("scope").notNull(),
accessToken: text("access_token").notNull(),
code: text("code").notNull(),
code: text("code"),
expiresAt: timestamp("expires_at", { precision: 3, mode: "string" }),
createdAt: timestamp("created_at", { precision: 3, mode: "string" })
.defaultNow()
.notNull(),
clientId: text("client_id").notNull().default(""),
redirectUri: text("redirect_uri").notNull().default(""),
idToken: text("id_token"),
userId: uuid("userId")
.references(() => Users.id, {
onDelete: "cascade",

View file

@ -43,6 +43,73 @@ try {
process.exit(1);
}
if (isEntry) {
// Check if JWT private key is set in config
if (!config.oidc.jwt_key) {
await dualServerLogger.log(
LogLevel.CRITICAL,
"Server",
"The JWT private key is not set in the config",
);
await dualServerLogger.log(
LogLevel.CRITICAL,
"Server",
"Below is a generated key for you to copy in the config at oidc.jwt_private_key",
);
// Generate a key for them
const keys = await crypto.subtle.generateKey("Ed25519", true, [
"sign",
"verify",
]);
const privateKey = Buffer.from(
await crypto.subtle.exportKey("pkcs8", keys.privateKey),
).toString("base64");
const publicKey = Buffer.from(
await crypto.subtle.exportKey("spki", keys.publicKey),
).toString("base64");
await dualServerLogger.log(
LogLevel.CRITICAL,
"Server",
`${privateKey};${publicKey}`,
);
process.exit(1);
}
// Try and import the key
const privateKey = await crypto.subtle
.importKey(
"pkcs8",
Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"),
"Ed25519",
false,
["sign"],
)
.catch((e) => e as Error);
// Try and import the key
const publicKey = await crypto.subtle
.importKey(
"spki",
Buffer.from(config.oidc.jwt_key.split(";")[1], "base64"),
"Ed25519",
false,
["verify"],
)
.catch((e) => e as Error);
if (privateKey instanceof Error || publicKey instanceof Error) {
await dualServerLogger.log(
LogLevel.CRITICAL,
"Server",
"The JWT key could not be imported! You may generate a new one by removing the old one from the config and restarting the server (this will invalidate all current JWTs).",
);
process.exit(1);
}
}
const server = createServer(config, dualServerLogger, true);
await dualServerLogger.log(

View file

@ -85,6 +85,7 @@
"ip-matching": "^2.1.2",
"iso-639-1": "^3.1.0",
"isomorphic-dompurify": "latest",
"jose": "^5.2.4",
"linkify-html": "^4.1.3",
"linkify-string": "^4.1.3",
"linkifyjs": "^4.1.3",

View file

@ -89,6 +89,8 @@ export interface Config {
client_secret: string;
icon: string;
}[];
jwt_key: string;
};
http: {
@ -447,6 +449,7 @@ export const defaultConfig: Config = {
},
oidc: {
providers: [],
jwt_key: "",
},
http: {
base_url: "https://lysand.social",

View file

@ -137,23 +137,14 @@ export class RequestParser {
* @throws Error if body is invalid
*/
private async parseFormUrlencoded<T>(): Promise<Partial<T>> {
const formData = await this.request.formData();
const result: Partial<T> = {};
const parsed = parse(await this.request.text(), {
parseArrays: true,
interpretNumericEntities: true,
});
for (const [key, value] of formData.entries()) {
if (key.endsWith("[]")) {
const arrayKey = key.slice(0, -2) as keyof T;
if (!result[arrayKey]) {
result[arrayKey] = [] as T[keyof T];
}
(result[arrayKey] as FormDataEntryValue[]).push(value);
} else {
result[key as keyof T] = value as T[keyof T];
}
}
return result;
return castBooleanObject(
parsed as PossiblyRecursiveObject,
) as Partial<T>;
}
/**

View file

@ -19,8 +19,8 @@ for (const [route, path] of Object.entries(routes)) {
export { routes };
export const matchRoute = (url: string) => {
const route = routeMatcher.match(url);
export const matchRoute = (request: Request) => {
const route = routeMatcher.match(request);
return route ?? null;
};

View file

@ -1,5 +1,6 @@
import { dualLogger } from "@loggers";
import { errorResponse, response } from "@response";
import type { MatchedRoute } from "bun";
import type { Config } from "config-manager";
import { matches } from "ip-matching";
import type { LogManager, MultiLogManager } from "log-manager";
@ -129,10 +130,19 @@ export const createServer = (
// If route is .well-known, remove dot because the filesystem router can't handle dots for some reason
const matchedRoute = matchRoute(
req.url.replace(".well-known", "well-known"),
new Request(req.url.replace(".well-known", "well-known"), {
method: req.method,
}),
);
if (matchedRoute?.filePath && matchedRoute.name !== "/[...404]") {
if (
matchedRoute?.filePath &&
matchedRoute.name !== "/[...404]" &&
!(
new URL(req.url).pathname.startsWith("/oauth/authorize") &&
req.method === "GET"
)
) {
return await processRoute(matchedRoute, req, logger);
}
@ -164,8 +174,6 @@ export const createServer = (
return null;
});
console.log(proxy);
if (!proxy || proxy.status === 404) {
if (config.frontend.glitch.enabled) {
return (

View file

@ -1,10 +1,13 @@
import { randomBytes } from "node:crypto";
import { apiRoute, applyConfig } from "@api";
import { z } from "zod";
import { TokenType } from "~database/entities/Token";
import { findFirstUser } from "~database/entities/User";
import { SignJWT } from "jose";
import { config } from "~packages/config-manager";
import { errorResponse, response } from "@response";
import { stringify } from "qs";
import { fromZodError } from "zod-validation-error";
import { RequestParser } from "~packages/request-parser";
import { db } from "~drizzle/db";
import { Tokens } from "~drizzle/schema";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -20,76 +23,139 @@ export const meta = applyConfig({
export const schema = z.object({
email: z.string().email(),
password: z.string().max(100).min(3),
password: z.string().min(2).max(100),
});
export const querySchema = z.object({
scope: z.string().optional(),
redirect_uri: z.string().url().optional(),
response_type: z.enum([
"code",
"token",
"none",
"id_token",
"code id_token",
"code token",
"token id_token",
"code token id_token",
]),
client_id: z.string(),
state: z.string().optional(),
code_challenge: z.string().optional(),
code_challenge_method: z.enum(["plain", "S256"]).optional(),
prompt: z
.enum(["none", "login", "consent", "select_account"])
.optional()
.default("none"),
max_age: z
.number()
.int()
.optional()
.default(60 * 60 * 24 * 7),
});
const returnError = (query: object, error: string, description: string) =>
response(null, 302, {
Location: `/oauth/authorize?${stringify({
...query,
error,
error_description: description,
})}`,
});
/**
* OAuth Code flow
* Login flow
*/
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const scopes = (matchedRoute.query.scope || "")
.replaceAll("+", " ")
.split(" ");
const redirect_uri = matchedRoute.query.redirect_uri;
const response_type = matchedRoute.query.response_type;
const client_id = matchedRoute.query.client_id;
export default apiRoute(async (req, matchedRoute, extraData) => {
const { email, password } = extraData.parsedRequest;
const redirectToLogin = (error: string) =>
Response.redirect(
`/oauth/authorize?${new URLSearchParams({
...matchedRoute.query,
error: encodeURIComponent(error),
}).toString()}`,
302,
if (!email || !password)
return returnError(
extraData.parsedRequest,
"invalid_request",
"Missing email or password",
);
if (response_type !== "code")
return redirectToLogin("Invalid response_type");
if (!email || !password)
return redirectToLogin("Invalid username or password");
// Find user
const user = await findFirstUser({
where: (user, { eq }) => eq(user.email, email),
});
if (
!user ||
!(await Bun.password.verify(password, user.password || ""))
)
return redirectToLogin("Invalid username or password");
if (!user || !(await Bun.password.verify(password, user.password || "")))
return returnError(
extraData.parsedRequest,
"invalid_request",
"Invalid email or password",
);
const parsedQuery = await new RequestParser(
new Request(req.url),
).toObject();
if (!parsedQuery) {
return errorResponse("Invalid query", 400);
}
const parsingResult = querySchema.safeParse(parsedQuery);
if (parsingResult && !parsingResult.success) {
// Return a 422 error with the first error message
return errorResponse(fromZodError(parsingResult.error).toString(), 422);
}
const { client_id } = parsingResult.data;
// Try and import the key
const privateKey = await crypto.subtle.importKey(
"pkcs8",
Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"),
"Ed25519",
false,
["sign"],
);
// Generate JWT
const jwt = await new SignJWT({
sub: user.id,
iss: new URL(config.http.base_url).origin,
aud: client_id,
exp: Math.floor(Date.now() / 1000) + 60 * 60,
iat: Math.floor(Date.now() / 1000),
nbf: Math.floor(Date.now() / 1000),
})
.setProtectedHeader({ alg: "EdDSA" })
.sign(privateKey);
const application = await db.query.Applications.findFirst({
where: (app, { eq }) => eq(app.clientId, client_id),
});
if (!application) return redirectToLogin("Invalid client_id");
if (!application) {
return errorResponse("Invalid application", 400);
}
const code = randomBytes(32).toString("hex");
await db.insert(Tokens).values({
accessToken: randomBytes(64).toString("base64url"),
code: code,
scope: scopes.join(" "),
tokenType: TokenType.BEARER,
applicationId: application.id,
userId: user.id,
const searchParams = new URLSearchParams({
application: application.name,
client_secret: application.secret,
});
// Redirect to OAuth confirmation screen
return Response.redirect(
`/oauth/redirect?${new URLSearchParams({
redirect_uri,
code,
client_id,
application: application.name,
website: application.website ?? "",
scope: scopes.join(" "),
}).toString()}`,
302,
);
},
);
if (application.website)
searchParams.append("website", application.website);
// Add all data that is not undefined
for (const [key, value] of Object.entries(parsingResult.data)) {
if (value !== undefined) searchParams.append(key, String(value));
}
// Redirect to OAuth authorize with JWT
return response(null, 302, {
Location: new URL(
`/oauth/redirect?${searchParams.toString()}`,
config.http.base_url,
).toString(),
// Set cookie with JWT
"Set-Cookie": `jwt=${jwt}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${
60 * 60
}`,
});
});

View file

@ -37,7 +37,7 @@ export default apiRoute<typeof meta, typeof schema>(
.insert(Applications)
.values({
name: client_name || "",
redirectUris: redirect_uris || "",
redirectUri: redirect_uris || "",
scopes: scopes || "read",
website: website || null,
clientId: randomBytes(32).toString("base64url"),
@ -52,7 +52,7 @@ export default apiRoute<typeof meta, typeof schema>(
website: app.website,
client_id: app.clientId,
client_secret: app.secret,
redirect_uri: app.redirectUris,
redirect_uri: app.redirectUri,
vapid_link: app.vapidKey,
});
},

View file

@ -31,7 +31,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
name: application.name,
website: application.website,
vapid_key: application.vapidKey,
redirect_uris: application.redirectUris,
redirect_uris: application.redirectUri,
scopes: application.scopes,
});
});

View file

@ -0,0 +1,289 @@
import { randomBytes } from "node:crypto";
import { apiRoute, applyConfig, idValidator } from "@api";
import { z } from "zod";
import { TokenType } from "~database/entities/Token";
import { findFirstUser } from "~database/entities/User";
import { db } from "~drizzle/db";
import { Tokens } from "~drizzle/schema";
import { response } from "@response";
import { jwtVerify, SignJWT } from "jose";
import { config } from "~packages/config-manager";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 4,
duration: 60,
},
route: "/oauth/authorize",
auth: {
required: false,
},
});
export const schema = z.object({
scope: z.string().optional(),
redirect_uri: z.string().url().optional(),
response_type: z.enum([
"code",
"token",
"none",
"id_token",
"code id_token",
"code token",
"token id_token",
"code token id_token",
]),
client_id: z.string(),
state: z.string().optional(),
code_challenge: z.string().optional(),
code_challenge_method: z.enum(["plain", "S256"]).optional(),
});
export const querySchema = z.object({
prompt: z
.enum(["none", "login", "consent", "select_account"])
.optional()
.default("none"),
max_age: z
.number()
.int()
.optional()
.default(60 * 60 * 24 * 7),
});
const returnError = (error: string, description: string) =>
response(null, 302, {
Location: new URL(
`/oauth/authorize?${new URLSearchParams({
error: error,
error_description: description,
}).toString()}`,
config.http.base_url,
).toString(),
});
/**
* OIDC Authorization
*/
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const {
scope,
redirect_uri,
response_type,
client_id,
state,
code_challenge,
code_challenge_method,
} = extraData.parsedRequest;
const cookie = req.headers.get("Cookie");
if (!cookie)
return returnError(
"invalid_request",
"No cookies were sent with the request",
);
const jwt = cookie
.split(";")
.find((c) => c.trim().startsWith("jwt="))
?.split("=")[1];
if (!jwt)
return returnError(
"invalid_request",
"No jwt cookie was sent in the request",
);
// Try and import the key
const privateKey = await crypto.subtle.importKey(
"pkcs8",
Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"),
"Ed25519",
true,
["sign"],
);
const publicKey = await crypto.subtle.importKey(
"spki",
Buffer.from(config.oidc.jwt_key.split(";")[1], "base64"),
"Ed25519",
true,
["verify"],
);
const result = await jwtVerify(jwt, publicKey, {
algorithms: ["EdDSA"],
issuer: new URL(config.http.base_url).origin,
audience: client_id,
}).catch((e) => {
console.error(e);
return null;
});
if (!result)
return returnError(
"invalid_request",
"Invalid JWT, could not verify",
);
const payload = result.payload;
if (!payload.sub) return returnError("invalid_request", "Invalid sub");
if (!payload.aud) return returnError("invalid_request", "Invalid aud");
if (!payload.exp) return returnError("invalid_request", "Invalid exp");
// Check if the user is authenticated
const user = await findFirstUser({
where: (user, { eq }) => eq(user.id, payload.sub ?? ""),
});
if (!user) return returnError("invalid_request", "Invalid sub");
const responseTypes = response_type.split(" ");
const asksCode = responseTypes.includes("code");
const asksToken = responseTypes.includes("token");
const asksIdToken = responseTypes.includes("id_token");
if (!asksCode && !asksToken && !asksIdToken)
return returnError(
"invalid_request",
"Invalid response_type, must ask for code, token, or id_token",
);
if (asksCode && !redirect_uri)
return returnError(
"invalid_request",
"Redirect URI is required for code flow",
);
/* if (asksCode && !code_challenge)
return returnError(
"invalid_request",
"Code challenge is required for code flow",
);
if (asksCode && !code_challenge_method)
return returnError(
"invalid_request",
"Code challenge method is required for code flow",
); */
// Authenticate the user
const application = await db.query.Applications.findFirst({
where: (app, { eq }) => eq(app.clientId, client_id),
});
if (!application)
return returnError(
"invalid_client",
"Invalid client_id or client_secret",
);
if (application.redirectUri !== redirect_uri)
return returnError(
"invalid_request",
"Redirect URI does not match client_id",
);
/* if (application.slate !== slate)
return returnError("invalid_request", "Invalid slate"); */
// Validate scopes, they can either be equal or a subset of the application's scopes
const applicationScopes = application.scopes.split(" ");
if (
scope &&
!scope.split(" ").every((s) => applicationScopes.includes(s))
)
return returnError("invalid_scope", "Invalid scope");
// Generate tokens
const code = randomBytes(256).toString("base64url");
// Handle the requested scopes
let idTokenPayload = {};
const scopeIncludesOpenID = scope?.split(" ").includes("openid");
const scopeIncludesProfile = scope?.split(" ").includes("profile");
const scopeIncludesEmail = scope?.split(" ").includes("email");
if (scope) {
const scopes = scope.split(" ");
if (scopeIncludesOpenID) {
// Include the standard OpenID claims
idTokenPayload = {
...idTokenPayload,
sub: user.id,
aud: client_id,
iss: new URL(config.http.base_url).origin,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 60 * 60,
};
}
if (scopeIncludesProfile) {
// Include the user's profile information
idTokenPayload = {
...idTokenPayload,
name: user.displayName,
preferred_username: user.username,
picture: user.avatar,
updated_at: new Date(user.updatedAt).toISOString(),
};
}
if (scopeIncludesEmail) {
// Include the user's email address
idTokenPayload = {
...idTokenPayload,
email: user.email,
email_verified: true,
};
}
}
const idToken = await new SignJWT(idTokenPayload)
.setProtectedHeader({
alg: "EdDSA",
})
.sign(privateKey);
await db.insert(Tokens).values({
accessToken: randomBytes(64).toString("base64url"),
code: code,
scope: scope ?? application.scopes,
tokenType: TokenType.BEARER,
applicationId: application.id,
redirectUri: redirect_uri ?? application.redirectUri,
expiresAt: new Date(Date.now() + 60 * 60 * 24 * 14).toISOString(),
idToken:
scopeIncludesOpenID ||
scopeIncludesEmail ||
scopeIncludesProfile
? idToken
: null,
clientId: client_id,
userId: user.id,
});
// Redirect to the client
const redirectUri = new URL(redirect_uri ?? application.redirectUri);
const searchParams = new URLSearchParams({
code: code,
scope: scope ?? application.scopes,
token_type: "Bearer",
client_id: client_id,
});
if (state) searchParams.set("state", state);
return response(null, 302, {
Location: `${redirectUri.origin}${
redirectUri.pathname
}?${searchParams.toString()}`,
"Cache-Control": "no-store",
Pragma: "no-cache",
});
},
);

View file

@ -1,7 +1,10 @@
import { apiRoute, applyConfig } from "@api";
import { errorResponse, jsonResponse } from "@response";
import { apiRoute, applyConfig, idValidator } from "@api";
import { jsonResponse } from "@response";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "~drizzle/db";
import { Tokens } from "~drizzle/schema";
import { config } from "~packages/config-manager";
export const meta = applyConfig({
allowedMethods: ["POST"],
@ -16,14 +19,43 @@ export const meta = applyConfig({
});
export const schema = z.object({
grant_type: z.string(),
code: z.string(),
redirect_uri: z.string().url(),
client_id: z.string(),
client_secret: z.string(),
scope: z.string(),
code: z.string().optional(),
code_verifier: z.string().optional(),
grant_type: z.enum([
"authorization_code",
"refresh_token",
"client_credentials",
"password",
"urn:ietf:params:oauth:grant-type:device_code",
"urn:ietf:params:oauth:grant-type:token-exchange",
"urn:ietf:params:oauth:grant-type:saml2-bearer",
"urn:openid:params:grant-type:ciba",
]),
client_id: z.string().optional(),
client_secret: z.string().optional(),
username: z.string().optional(),
password: z.string().optional(),
redirect_uri: z.string().url().optional(),
refresh_token: z.string().optional(),
scope: z.string().optional(),
assertion: z.string().optional(),
audience: z.string().optional(),
subject_token_type: z.string().optional(),
subject_token: z.string().optional(),
actor_token_type: z.string().optional(),
actor_token: z.string().optional(),
auth_req_id: z.string().optional(),
});
const returnError = (error: string, description: string) =>
jsonResponse(
{
error,
error_description: description,
},
401,
);
/**
* Allows getting token from OAuth code
*/
@ -33,50 +65,78 @@ export default apiRoute<typeof meta, typeof schema>(
grant_type,
code,
redirect_uri,
scope,
client_id,
client_secret,
scope,
} = extraData.parsedRequest;
if (grant_type !== "authorization_code")
return errorResponse(
"Invalid grant type (try 'authorization_code')",
422,
);
switch (grant_type) {
case "authorization_code": {
if (!code) {
return returnError("invalid_request", "Code is required");
}
// Get associated token
const application = await db.query.Applications.findFirst({
where: (application, { eq, and }) =>
and(
if (!redirect_uri) {
return returnError(
"invalid_request",
"Redirect URI is required",
);
}
if (!client_id) {
return returnError(
"invalid_client",
"Client ID is required",
);
}
// Verify the client_secret
const client = await db.query.Applications.findFirst({
where: (application, { eq }) =>
eq(application.clientId, client_id),
eq(application.secret, client_secret),
eq(application.redirectUris, redirect_uri),
eq(application.scopes, scope?.replaceAll("+", " ")),
});
if (!client || client.secret !== client_secret) {
return returnError(
"invalid_client",
"Invalid client credentials",
);
}
const token = await db.query.Tokens.findFirst({
where: (token, { eq, and }) =>
and(
eq(token.code, code),
eq(token.redirectUri, redirect_uri),
eq(token.clientId, client_id),
),
});
if (!application)
return errorResponse(
"Invalid client credentials (missing application)",
401,
);
if (!token) {
return returnError("invalid_grant", "Code not found");
}
const token = await db.query.Tokens.findFirst({
where: (token, { eq }) =>
eq(token.code, code) && eq(token.applicationId, application.id),
});
if (!token)
return errorResponse(
"Invalid access token or client credentials",
401,
);
// Invalidate the code
await db
.update(Tokens)
.set({ code: null })
.where(eq(Tokens.id, token.id));
return jsonResponse({
access_token: token.accessToken,
token_type: token.tokenType,
token_type: "Bearer",
expires_in: token.expiresAt
? (new Date(token.expiresAt).getTime() - Date.now()) /
1000
: null,
id_token: token.idToken,
refresh_token: null,
scope: token.scope,
created_at: new Date(token.createdAt).getTime(),
created_at: new Date(token.createdAt).toISOString(),
});
}
}
return returnError("unsupported_grant_type", "Unsupported grant type");
},
);

View file

@ -86,7 +86,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
const public_key = await crypto.subtle.importKey(
"spki",
Uint8Array.from(atob(sender.publicKey), (c) => c.charCodeAt(0)),
Buffer.from(sender.publicKey, "base64"),
"Ed25519",
false,
["verify"],

View file

@ -0,0 +1,42 @@
import { apiRoute, applyConfig } from "@api";
import { jsonResponse } from "@response";
import { config } from "~packages/config-manager";
import { exportJWK, createRemoteJWKSet } from "jose";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 30,
max: 60,
},
route: "/.well-known/jwks",
});
export default apiRoute(async (req, matchedRoute, extraData) => {
const publicKey = await crypto.subtle.importKey(
"spki",
Buffer.from(config.oidc.jwt_key.split(";")[1], "base64"),
"Ed25519",
true,
["verify"],
);
const jwk = await exportJWK(publicKey);
// Remove the private key
jwk.d = undefined;
return jsonResponse({
keys: [
{
...jwk,
use: "sig",
alg: "EdDSA",
kid: "1",
},
],
});
});

View file

@ -0,0 +1,32 @@
import { apiRoute, applyConfig } from "@api";
import { jsonResponse } from "@response";
import { config } from "~packages/config-manager";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 30,
max: 60,
},
route: "/.well-known/openid-configuration",
});
export default apiRoute(async (req, matchedRoute, extraData) => {
const base_url = new URL(config.http.base_url);
return jsonResponse({
issuer: base_url.origin.toString(),
authorization_endpoint: `${base_url.origin}/oauth/authorize`,
token_endpoint: `${base_url.origin}/oauth/token`,
userinfo_endpoint: `${base_url.origin}/api/v1/accounts/verify_credentials`,
jwks_uri: `${base_url.origin}/.well-known/jwks`,
response_types_supported: ["code"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["EdDSA"],
scopes_supported: ["openid", "profile", "email"],
token_endpoint_auth_methods_supported: ["client_secret_basic"],
claims_supported: ["sub"],
});
});

View file

@ -13,6 +13,7 @@ const base_url = "http://lysand.localhost:8080"; //config.http.base_url;
let client_id: string;
let client_secret: string;
let code: string;
let jwt: string;
let token: APIToken;
const { users, passwords, deleteUsers } = await getTestUsers(1);
@ -57,7 +58,7 @@ describe("POST /api/v1/apps/", () => {
});
describe("POST /api/auth/login/", () => {
test("should get a code", async () => {
test("should get a JWT", async () => {
const formData = new FormData();
formData.append("email", users[0]?.email ?? "");
@ -77,33 +78,80 @@ describe("POST /api/auth/login/", () => {
);
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toMatch(
/^\/oauth\/redirect\?redirect_uri=https%3A%2F%2Fexample.com&code=[a-f0-9]+&client_id=[a-zA-Z0-9_-]+&application=Test\+Application&website=https%3A%2F%2Fexample.com&scope=read\+write$/,
expect(response.headers.get("location")).toBeDefined();
const locationHeader = new URL(
response.headers.get("Location") ?? "",
"",
);
code =
new URL(
expect(locationHeader.pathname).toBe("/oauth/redirect");
expect(locationHeader.searchParams.get("client_id")).toBe(client_id);
expect(locationHeader.searchParams.get("redirect_uri")).toBe(
"https://example.com",
);
expect(locationHeader.searchParams.get("response_type")).toBe("code");
expect(locationHeader.searchParams.get("scope")).toBe("read write");
expect(response.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/);
jwt =
response.headers.get("Set-Cookie")?.match(/jwt=([^;]+);/)?.[1] ??
"";
});
});
describe("POST /oauth/authorize/", () => {
test("should get a code", async () => {
const response = await sendTestRequest(
new Request(wrapRelativeUrl("/oauth/authorize", base_url), {
method: "POST",
headers: {
Cookie: `jwt=${jwt}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id,
client_secret,
redirect_uri: "https://example.com",
response_type: "code",
scope: "read write",
max_age: "604800",
}),
}),
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBeDefined();
const locationHeader = new URL(
response.headers.get("Location") ?? "",
"http://lysand.localhost:8080",
).searchParams.get("code") ?? "";
"",
);
expect(locationHeader.origin).toBe("https://example.com");
expect(locationHeader.searchParams.get("client_id")).toBe(client_id);
expect(locationHeader.searchParams.get("scope")).toBe("read write");
code = locationHeader.searchParams.get("code") ?? "";
});
});
describe("POST /oauth/token/", () => {
test("should get an access token", async () => {
const formData = new FormData();
formData.append("grant_type", "authorization_code");
formData.append("code", code);
formData.append("redirect_uri", "https://example.com");
formData.append("client_id", client_id);
formData.append("client_secret", client_secret);
formData.append("scope", "read+write");
const response = await sendTestRequest(
new Request(wrapRelativeUrl("/oauth/token/", base_url), {
method: "POST",
body: formData,
headers: {
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: "https://example.com",
client_id,
client_secret,
scope: "read write",
}),
}),
);
@ -115,7 +163,10 @@ describe("POST /oauth/token/", () => {
access_token: expect.any(String),
token_type: "Bearer",
scope: "read write",
created_at: expect.any(Number),
created_at: expect.any(String),
expires_in: expect.any(Number),
id_token: null,
refresh_token: null,
});
token = json;