refactor(plugin): 🚚 Move SSO login callback route to OpenID plugin

This commit is contained in:
Jesse Wierzbinski 2024-10-11 15:15:06 +02:00
parent 777a39faf5
commit 04651746bb
No known key found for this signature in database
7 changed files with 551 additions and 648 deletions

View file

@ -1,305 +0,0 @@
import { apiRoute, applyConfig } from "@/api";
import { randomString } from "@/math";
import { setCookie } from "@hono/hono/cookie";
import { createRoute } from "@hono/zod-openapi";
import { and, eq, isNull } from "drizzle-orm";
import type { Context } from "hono";
import { SignJWT } from "jose";
import { z } from "zod";
import { TokenType } from "~/classes/functions/token";
import { db } from "~/drizzle/db";
import { RolePermissions, Tokens, Users } from "~/drizzle/schema";
import { config } from "~/packages/config-manager";
import { OAuthManager } from "~/packages/database-interface/oauth";
import { User } from "~/packages/database-interface/user";
export const meta = applyConfig({
auth: {
required: false,
},
ratelimits: {
duration: 60,
max: 20,
},
route: "/oauth/sso/:issuer/callback",
});
export const schemas = {
query: z.object({
client_id: z.string().optional(),
flow: z.string(),
link: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.optional(),
user_id: z.string().uuid().optional(),
}),
param: z.object({
issuer: z.string(),
}),
};
const route = createRoute({
method: "get",
path: "/oauth/sso/{issuer}/callback",
summary: "SSO callback",
description:
"After the user has authenticated to an external OpenID provider, they are redirected here to complete the OAuth flow and get a code",
request: {
query: schemas.query,
params: schemas.param,
},
responses: {
302: {
description:
"Redirect to frontend's consent route, or redirect to login page with error",
},
},
});
const returnError = (
context: Context,
query: object,
error: string,
description: string,
) => {
const searchParams = new URLSearchParams();
// Add all data that is not undefined except email and password
for (const [key, value] of Object.entries(query)) {
if (key !== "email" && key !== "password" && value !== undefined) {
searchParams.append(key, value);
}
}
searchParams.append("error", error);
searchParams.append("error_description", description);
return context.redirect(
`${config.frontend.routes.login}?${searchParams.toString()}`,
);
};
export default apiRoute((app) =>
app.openapi(route, async (context) => {
const currentUrl = new URL(context.req.url);
const redirectUrl = new URL(context.req.url);
// Correct some reverse proxies incorrectly setting the protocol as http, even if the original request was https
// Looking at you, Traefik
if (
new URL(config.http.base_url).protocol === "https:" &&
currentUrl.protocol === "http:"
) {
currentUrl.protocol = "https:";
redirectUrl.protocol = "https:";
}
// Remove state query parameter from URL
currentUrl.searchParams.delete("state");
redirectUrl.searchParams.delete("state");
// Remove issuer query parameter from URL (can cause redirect URI mismatches)
redirectUrl.searchParams.delete("iss");
redirectUrl.searchParams.delete("code");
const { issuer: issuerParam } = context.req.valid("param");
const { flow: flowId, user_id, link } = context.req.valid("query");
const manager = new OAuthManager(issuerParam);
const userInfo = await manager.automaticOidcFlow(
flowId,
currentUrl,
redirectUrl,
(error, message, app) =>
returnError(
context,
OAuthManager.processOAuth2Error(app),
error,
message,
),
);
if (userInfo instanceof Response) {
return userInfo;
}
const { sub, email, preferred_username, picture } = userInfo.userInfo;
const flow = userInfo.flow;
// If linking account
if (link && user_id) {
return await manager.linkUser(user_id, context, userInfo);
}
let userId = (
await db.query.OpenIdAccounts.findFirst({
where: (account, { eq, and }) =>
and(
eq(account.serverId, sub),
eq(account.issuerId, manager.issuer.id),
),
})
)?.userId;
if (!userId) {
// Register new user
if (config.signups.registration && config.oidc.allow_registration) {
let username =
preferred_username ??
email?.split("@")[0] ??
randomString(8, "hex");
const usernameValidator = z
.string()
.regex(/^[a-z0-9_]+$/)
.min(3)
.max(config.validation.max_username_size)
.refine(
(value) =>
!config.validation.username_blacklist.includes(
value,
),
)
.refine((value) =>
config.filters.username.some((filter) =>
value.match(filter),
),
)
.refine(
async (value) =>
!(await User.fromSql(
and(
eq(Users.username, value),
isNull(Users.instanceId),
),
)),
);
try {
await usernameValidator.parseAsync(username);
} catch {
username = randomString(8, "hex");
}
const doesEmailExist = email
? !!(await User.fromSql(eq(Users.email, email)))
: false;
// Create new user
const user = await User.fromDataLocal({
email: doesEmailExist ? undefined : email,
username,
avatar: picture,
password: undefined,
});
// Link account
await manager.linkUserInDatabase(user.id, sub);
userId = user.id;
} else {
return returnError(
context,
{
redirect_uri: flow.application?.redirectUri,
client_id: flow.application?.clientId,
response_type: "code",
scope: flow.application?.scopes,
},
"invalid_request",
"No user found with that account",
);
}
}
const user = await User.fromId(userId);
if (!user) {
return returnError(
context,
{
redirect_uri: flow.application?.redirectUri,
client_id: flow.application?.clientId,
response_type: "code",
scope: flow.application?.scopes,
},
"invalid_request",
"No user found with that account",
);
}
if (!user.hasPermission(RolePermissions.OAuth)) {
return returnError(
context,
{
redirect_uri: flow.application?.redirectUri,
client_id: flow.application?.clientId,
response_type: "code",
scope: flow.application?.scopes,
},
"invalid_request",
`User does not have the '${RolePermissions.OAuth}' permission`,
);
}
if (!flow.application) {
return context.json({ error: "Application not found" }, 500);
}
const code = randomString(32, "hex");
await db.insert(Tokens).values({
accessToken: randomString(64, "base64url"),
code,
scope: flow.application.scopes,
tokenType: TokenType.Bearer,
userId: user.id,
applicationId: flow.application.id,
});
// Try and import the key
const privateKey = await crypto.subtle.importKey(
"pkcs8",
Buffer.from(config.oidc.keys?.private ?? "", "base64"),
"Ed25519",
false,
["sign"],
);
// Generate JWT
const jwt = await new SignJWT({
sub: user.id,
iss: new URL(config.http.base_url).origin,
aud: flow.application.clientId,
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);
// Redirect back to application
setCookie(context, "jwt", jwt, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/",
maxAge: 60 * 60,
});
return context.redirect(
new URL(
`${config.frontend.routes.consent}?${new URLSearchParams({
redirect_uri: flow.application.redirectUri,
code,
client_id: flow.application.clientId,
application: flow.application.name,
website: flow.application.website ?? "",
scope: flow.application.scopes,
response_type: "code",
}).toString()}`,
config.http.base_url,
).toString(),
);
}),
);

View file

@ -1,281 +0,0 @@
import type { InferInsertModel } from "drizzle-orm";
import type { Context } from "hono";
import {
type AuthorizationServer,
authorizationCodeGrantRequest,
discoveryRequest,
expectNoState,
getValidatedIdTokenClaims,
isOAuth2Error,
processAuthorizationCodeOpenIDResponse,
processDiscoveryResponse,
processUserInfoResponse,
userInfoRequest,
validateAuthResponse,
} from "oauth4webapi";
import type { Application } from "~/classes/functions/application";
import { db } from "~/drizzle/db";
import { type Applications, OpenIdAccounts } from "~/drizzle/schema";
import { config } from "~/packages/config-manager";
export class OAuthManager {
public issuer: (typeof config.oidc.providers)[0];
constructor(public issuerId: string) {
const found = config.oidc.providers.find(
(provider) => provider.id === this.issuerId,
);
if (!found) {
throw new Error(`Issuer ${this.issuerId} not found`);
}
this.issuer = found;
}
static async getFlow(flowId: string) {
return await db.query.OpenIdLoginFlows.findFirst({
where: (flow, { eq }) => eq(flow.id, flowId),
with: {
application: true,
},
});
}
static async getAuthServer(issuerUrl: URL) {
return await discoveryRequest(issuerUrl, {
algorithm: "oidc",
}).then((res) => processDiscoveryResponse(issuerUrl, res));
}
static getParameters(
authServer: AuthorizationServer,
issuer: (typeof config.oidc.providers)[0],
currentUrl: URL,
) {
return validateAuthResponse(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
currentUrl,
expectNoState,
);
}
static async getOIDCResponse(
authServer: AuthorizationServer,
issuer: (typeof config.oidc.providers)[0],
redirectUri: string,
codeVerifier: string,
parameters: URLSearchParams,
) {
return await authorizationCodeGrantRequest(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
parameters,
redirectUri,
codeVerifier,
);
}
static async processOIDCResponse(
authServer: AuthorizationServer,
issuer: (typeof config.oidc.providers)[0],
oidcResponse: Response,
) {
return await processAuthorizationCodeOpenIDResponse(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
oidcResponse,
);
}
static async getUserInfo(
authServer: AuthorizationServer,
issuer: (typeof config.oidc.providers)[0],
accessToken: string,
sub: string,
) {
return await userInfoRequest(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
accessToken,
).then(
async (res) =>
await processUserInfoResponse(
authServer,
{
client_id: issuer.client_id,
client_secret: issuer.client_secret,
},
sub,
res,
),
);
}
static processOAuth2Error(
application: InferInsertModel<typeof Applications> | null,
) {
return {
redirect_uri: application?.redirectUri,
client_id: application?.clientId,
response_type: "code",
scope: application?.scopes,
};
}
async linkUserInDatabase(userId: string, sub: string): Promise<void> {
await db.insert(OpenIdAccounts).values({
serverId: sub,
issuerId: this.issuer.id,
userId,
});
}
async linkUser(
userId: string,
context: Context,
// Return value of automaticOidcFlow
oidcFlowData: Exclude<
Awaited<
ReturnType<typeof OAuthManager.prototype.automaticOidcFlow>
>,
Response
>,
) {
const { flow, userInfo } = oidcFlowData;
// Check if userId is equal to application.clientId
if (!flow.application?.clientId.startsWith(userId)) {
return context.redirect(
`${config.http.base_url}${
config.frontend.routes.home
}?${new URLSearchParams({
oidc_account_linking_error: "Account linking error",
oidc_account_linking_error_message: `User ID does not match application client ID (${userId} != ${flow.application?.clientId})`,
})}`,
);
}
// Check if account is already linked
const account = await db.query.OpenIdAccounts.findFirst({
where: (account, { eq, and }) =>
and(
eq(account.serverId, userInfo.sub),
eq(account.issuerId, this.issuer.id),
),
});
if (account) {
return context.redirect(
`${config.http.base_url}${
config.frontend.routes.home
}?${new URLSearchParams({
oidc_account_linking_error: "Account already linked",
oidc_account_linking_error_message:
"This account has already been linked to this OpenID Connect provider.",
})}`,
);
}
// Link the account
await this.linkUserInDatabase(userId, userInfo.sub);
return context.redirect(
`${config.http.base_url}${
config.frontend.routes.home
}?${new URLSearchParams({
oidc_account_linked: "true",
})}`,
);
}
async automaticOidcFlow(
flowId: string,
currentUrl: URL,
redirectUrl: URL,
errorFn: (
error: string,
message: string,
app: Application | null,
) => Response,
) {
const flow = await OAuthManager.getFlow(flowId);
if (!flow) {
return errorFn("invalid_request", "Invalid flow", null);
}
const issuerUrl = new URL(this.issuer.url);
const authServer = await OAuthManager.getAuthServer(issuerUrl);
const parameters = await OAuthManager.getParameters(
authServer,
this.issuer,
currentUrl,
);
if (isOAuth2Error(parameters)) {
return errorFn(
parameters.error,
parameters.error_description || "",
flow.application,
);
}
const oidcResponse = await OAuthManager.getOIDCResponse(
authServer,
this.issuer,
redirectUrl.toString(),
flow.codeVerifier,
parameters,
);
const result = await OAuthManager.processOIDCResponse(
authServer,
this.issuer,
oidcResponse,
);
if (isOAuth2Error(result)) {
return errorFn(
result.error,
result.error_description || "",
flow.application,
);
}
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
const userInfo = await OAuthManager.getUserInfo(
authServer,
this.issuer,
access_token,
sub,
);
return {
userInfo,
flow,
claims,
};
}
}

View file

@ -2,6 +2,7 @@ import { Hooks, Plugin } from "@versia/kit";
import { z } from "zod"; import { z } from "zod";
import authorizeRoute from "./routes/authorize.ts"; import authorizeRoute from "./routes/authorize.ts";
import jwksRoute from "./routes/jwks.ts"; import jwksRoute from "./routes/jwks.ts";
import ssoLoginCallbackRoute from "./routes/oauth/callback.ts";
import tokenRevokeRoute from "./routes/oauth/revoke.ts"; import tokenRevokeRoute from "./routes/oauth/revoke.ts";
import ssoLoginRoute from "./routes/oauth/sso.ts"; import ssoLoginRoute from "./routes/oauth/sso.ts";
import tokenRoute from "./routes/oauth/token.ts"; import tokenRoute from "./routes/oauth/token.ts";
@ -78,6 +79,7 @@ tokenRoute(plugin);
tokenRevokeRoute(plugin); tokenRevokeRoute(plugin);
jwksRoute(plugin); jwksRoute(plugin);
ssoLoginRoute(plugin); ssoLoginRoute(plugin);
ssoLoginCallbackRoute(plugin);
export type PluginType = typeof plugin; export type PluginType = typeof plugin;
export default plugin; export default plugin;

View file

@ -0,0 +1,353 @@
import { randomString } from "@/math.ts";
import { setCookie } from "@hono/hono/cookie";
import { createRoute, z } from "@hono/zod-openapi";
import { User, db } from "@versia/kit/db";
import { and, eq, isNull } from "@versia/kit/drizzle";
import {
OpenIdAccounts,
RolePermissions,
Tokens,
Users,
} from "@versia/kit/tables";
import { SignJWT } from "jose";
import { TokenType } from "~/classes/functions/token.ts";
import type { PluginType } from "../../index.ts";
import { automaticOidcFlow } from "../../utils.ts";
export const schemas = {
query: z.object({
client_id: z.string().optional(),
flow: z.string(),
link: z
.string()
.transform((v) => ["true", "1", "on"].includes(v.toLowerCase()))
.optional(),
user_id: z.string().uuid().optional(),
}),
param: z.object({
issuer: z.string(),
}),
};
export default (plugin: PluginType) => {
plugin.registerRoute("/oauth/sso/{issuer}/callback", (app) => {
app.openapi(
createRoute({
method: "get",
path: "/oauth/sso/{issuer}/callback",
summary: "SSO callback",
description:
"After the user has authenticated to an external OpenID provider, they are redirected here to complete the OAuth flow and get a code",
request: {
query: schemas.query,
params: schemas.param,
},
responses: {
302: {
description:
"Redirect to frontend's consent route, or redirect to login page with error",
},
},
}),
async (context) => {
const currentUrl = new URL(context.req.url);
const redirectUrl = new URL(context.req.url);
// Correct some reverse proxies incorrectly setting the protocol as http, even if the original request was https
// Looking at you, Traefik
if (
new URL(context.get("config").http.base_url).protocol ===
"https:" &&
currentUrl.protocol === "http:"
) {
currentUrl.protocol = "https:";
redirectUrl.protocol = "https:";
}
// Remove state query parameter from URL
currentUrl.searchParams.delete("state");
redirectUrl.searchParams.delete("state");
// Remove issuer query parameter from URL (can cause redirect URI mismatches)
redirectUrl.searchParams.delete("iss");
redirectUrl.searchParams.delete("code");
const { issuer: issuerParam } = context.req.valid("param");
const {
flow: flowId,
user_id,
link,
} = context.req.valid("query");
const issuer = context
.get("pluginConfig")
.providers.find((provider) => provider.id === issuerParam);
if (!issuer) {
return context.json({ error: "Issuer not found" }, 404);
}
const userInfo = await automaticOidcFlow(
issuer,
flowId,
currentUrl,
redirectUrl,
(error, message) => {
errorSearchParams.append("error", error);
errorSearchParams.append("error_description", message);
return context.redirect(
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
);
},
);
if (userInfo instanceof Response) {
return userInfo;
}
const { sub, email, preferred_username, picture } =
userInfo.userInfo;
const flow = userInfo.flow;
const errorSearchParams = new URLSearchParams(
Object.entries({
redirect_uri: flow.application?.redirectUri,
client_id: flow.application?.clientId,
response_type: "code",
scope: flow.application?.scopes,
}).filter(([_, value]) => value !== undefined) as [
string,
string,
][],
);
// If linking account
if (link && user_id) {
// Check if userId is equal to application.clientId
if (!flow.application?.clientId.startsWith(user_id)) {
return context.redirect(
`${context.get("config").http.base_url}${
context.get("config").frontend.routes.home
}?${new URLSearchParams({
oidc_account_linking_error:
"Account linking error",
oidc_account_linking_error_message: `User ID does not match application client ID (${user_id} != ${flow.application?.clientId})`,
})}`,
);
}
// Check if account is already linked
const account = await db.query.OpenIdAccounts.findFirst({
where: (account, { eq, and }) =>
and(
eq(account.serverId, sub),
eq(account.issuerId, issuer.id),
),
});
if (account) {
return context.redirect(
`${context.get("config").http.base_url}${
context.get("config").frontend.routes.home
}?${new URLSearchParams({
oidc_account_linking_error:
"Account already linked",
oidc_account_linking_error_message:
"This account has already been linked to this OpenID Connect provider.",
})}`,
);
}
// Link the account
await db.insert(OpenIdAccounts).values({
serverId: sub,
issuerId: issuer.id,
userId: user_id,
});
return context.redirect(
`${context.get("config").http.base_url}${
context.get("config").frontend.routes.home
}?${new URLSearchParams({
oidc_account_linked: "true",
})}`,
);
}
let userId = (
await db.query.OpenIdAccounts.findFirst({
where: (account, { eq, and }) =>
and(
eq(account.serverId, sub),
eq(account.issuerId, issuer.id),
),
})
)?.userId;
if (!userId) {
// Register new user
if (
context.get("config").signups.registration &&
context.get("pluginConfig").allow_registration
) {
let username =
preferred_username ??
email?.split("@")[0] ??
randomString(8, "hex");
const usernameValidator = z
.string()
.regex(/^[a-z0-9_]+$/)
.min(3)
.max(
context.get("config").validation
.max_username_size,
)
.refine(
(value) =>
!context
.get("config")
.validation.username_blacklist.includes(
value,
),
)
.refine((value) =>
context
.get("config")
.filters.username.some((filter) =>
value.match(filter),
),
)
.refine(
async (value) =>
!(await User.fromSql(
and(
eq(Users.username, value),
isNull(Users.instanceId),
),
)),
);
try {
await usernameValidator.parseAsync(username);
} catch {
username = randomString(8, "hex");
}
const doesEmailExist = email
? !!(await User.fromSql(eq(Users.email, email)))
: false;
// Create new user
const user = await User.fromDataLocal({
email: doesEmailExist ? undefined : email,
username,
avatar: picture,
password: undefined,
});
// Link account
await db.insert(OpenIdAccounts).values({
serverId: sub,
issuerId: issuer.id,
userId: user.id,
});
userId = user.id;
} else {
errorSearchParams.append("error", "invalid_request");
errorSearchParams.append(
"error_description",
"No user found with that account",
);
return context.redirect(
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
);
}
}
const user = await User.fromId(userId);
if (!user) {
errorSearchParams.append("error", "invalid_request");
errorSearchParams.append(
"error_description",
"No user found with that account",
);
return context.redirect(
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
);
}
if (!user.hasPermission(RolePermissions.OAuth)) {
errorSearchParams.append("error", "invalid_request");
errorSearchParams.append(
"error_description",
`User does not have the '${RolePermissions.OAuth}' permission`,
);
return context.redirect(
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
);
}
if (!flow.application) {
return context.json(
{ error: "Application not found" },
500,
);
}
const code = randomString(32, "hex");
await db.insert(Tokens).values({
accessToken: randomString(64, "base64url"),
code,
scope: flow.application.scopes,
tokenType: TokenType.Bearer,
userId: user.id,
applicationId: flow.application.id,
});
// Generate JWT
const jwt = await new SignJWT({
sub: user.id,
iss: new URL(context.get("config").http.base_url).origin,
aud: flow.application.clientId,
exp: Math.floor(Date.now() / 1000) + 60 * 60,
iat: Math.floor(Date.now() / 1000),
nbf: Math.floor(Date.now() / 1000),
})
.setProtectedHeader({ alg: "EdDSA" })
.sign(context.get("pluginConfig").keys?.private);
// Redirect back to application
setCookie(context, "jwt", jwt, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/",
maxAge: 60 * 60,
});
return context.redirect(
new URL(
`${context.get("config").frontend.routes.consent}?${new URLSearchParams(
{
redirect_uri: flow.application.redirectUri,
code,
client_id: flow.application.clientId,
application: flow.application.name,
website: flow.application.website ?? "",
scope: flow.application.scopes,
response_type: "code",
},
).toString()}`,
context.get("config").http.base_url,
).toString(),
);
},
);
});
};

View file

@ -1,12 +1,12 @@
import { createRoute, z } from "@hono/zod-openapi"; import { createRoute, z } from "@hono/zod-openapi";
import { db } from "@versia/kit/db";
import { OpenIdLoginFlows } from "@versia/kit/tables";
import { import {
calculatePKCECodeChallenge, calculatePKCECodeChallenge,
discoveryRequest, discoveryRequest,
generateRandomCodeVerifier, generateRandomCodeVerifier,
processDiscoveryResponse, processDiscoveryResponse,
} from "oauth4webapi"; } from "oauth4webapi";
import { db } from "~/drizzle/db.ts";
import { OpenIdLoginFlows } from "~/drizzle/schema.ts";
import type { PluginType } from "../../index.ts"; import type { PluginType } from "../../index.ts";
import { oauthRedirectUri } from "../../utils.ts"; import { oauthRedirectUri } from "../../utils.ts";

View file

@ -1,8 +1,20 @@
import { db } from "@versia/kit/db";
import { import {
type AuthorizationServer, type AuthorizationServer,
type OAuth2Error,
type OpenIDTokenEndpointResponse,
authorizationCodeGrantRequest,
discoveryRequest, discoveryRequest,
expectNoState,
getValidatedIdTokenClaims,
isOAuth2Error,
processAuthorizationCodeOpenIDResponse,
processDiscoveryResponse, processDiscoveryResponse,
processUserInfoResponse,
userInfoRequest,
validateAuthResponse,
} from "oauth4webapi"; } from "oauth4webapi";
import type { Application } from "~/classes/functions/application";
export const oauthDiscoveryRequest = ( export const oauthDiscoveryRequest = (
issuerUrl: string | URL, issuerUrl: string | URL,
@ -16,3 +28,185 @@ export const oauthDiscoveryRequest = (
export const oauthRedirectUri = (baseUrl: string, issuer: string) => export const oauthRedirectUri = (baseUrl: string, issuer: string) =>
new URL(`/oauth/sso/${issuer}/callback`, baseUrl).toString(); new URL(`/oauth/sso/${issuer}/callback`, baseUrl).toString();
const getFlow = (flowId: string) => {
return db.query.OpenIdLoginFlows.findFirst({
where: (flow, { eq }) => eq(flow.id, flowId),
with: {
application: true,
},
});
};
const getAuthServer = (issuerUrl: URL): Promise<AuthorizationServer> => {
return discoveryRequest(issuerUrl, {
algorithm: "oidc",
}).then((res) => processDiscoveryResponse(issuerUrl, res));
};
const getParameters = (
authServer: AuthorizationServer,
clientId: string,
clientSecret: string,
currentUrl: URL,
): URLSearchParams | OAuth2Error => {
return validateAuthResponse(
authServer,
{
client_id: clientId,
client_secret: clientSecret,
},
currentUrl,
expectNoState,
);
};
const getOIDCResponse = (
authServer: AuthorizationServer,
clientId: string,
clientSecret: string,
redirectUri: string,
codeVerifier: string,
parameters: URLSearchParams,
): Promise<Response> => {
return authorizationCodeGrantRequest(
authServer,
{
client_id: clientId,
client_secret: clientSecret,
},
parameters,
redirectUri,
codeVerifier,
);
};
const processOIDCResponse = (
authServer: AuthorizationServer,
clientId: string,
clientSecret: string,
oidcResponse: Response,
): Promise<OpenIDTokenEndpointResponse | OAuth2Error> => {
return processAuthorizationCodeOpenIDResponse(
authServer,
{
client_id: clientId,
client_secret: clientSecret,
},
oidcResponse,
);
};
const getUserInfo = (
authServer: AuthorizationServer,
clientId: string,
clientSecret: string,
accessToken: string,
sub: string,
) => {
return userInfoRequest(
authServer,
{
client_id: clientId,
client_secret: clientSecret,
},
accessToken,
).then(
async (res) =>
await processUserInfoResponse(
authServer,
{
client_id: clientId,
client_secret: clientId,
},
sub,
res,
),
);
};
export const automaticOidcFlow = async (
issuer: {
url: string;
client_id: string;
client_secret: string;
},
flowId: string,
currentUrl: URL,
redirectUrl: URL,
errorFn: (
error: string,
message: string,
app: Application | null,
) => Response,
) => {
const flow = await getFlow(flowId);
if (!flow) {
return errorFn("invalid_request", "Invalid flow", null);
}
const issuerUrl = new URL(issuer.url);
const authServer = await getAuthServer(issuerUrl);
const parameters = await getParameters(
authServer,
issuer.client_id,
issuer.client_secret,
currentUrl,
);
if (isOAuth2Error(parameters)) {
return errorFn(
parameters.error,
parameters.error_description || "",
flow.application,
);
}
const oidcResponse = await getOIDCResponse(
authServer,
issuer.client_id,
issuer.client_secret,
redirectUrl.toString(),
flow.codeVerifier,
parameters,
);
const result = await processOIDCResponse(
authServer,
issuer.client_id,
issuer.client_secret,
oidcResponse,
);
if (isOAuth2Error(result)) {
return errorFn(
result.error,
result.error_description || "",
flow.application,
);
}
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
const userInfo = await getUserInfo(
authServer,
issuer.client_id,
issuer.client_secret,
access_token,
sub,
);
return {
userInfo,
flow,
claims,
};
};

View file

@ -4,8 +4,6 @@ import type { Config } from "~/packages/config-manager";
import { User } from "~/packages/database-interface/user"; import { User } from "~/packages/database-interface/user";
export const checkConfig = async (config: Config) => { export const checkConfig = async (config: Config) => {
await checkOidcConfig(config);
await checkFederationConfig(config); await checkFederationConfig(config);
await checkHttpProxyConfig(config); await checkHttpProxyConfig(config);
@ -67,64 +65,6 @@ const checkChallengeConfig = async (config: Config) => {
} }
}; };
const checkOidcConfig = async (config: Config) => {
const logger = getLogger("server");
if (!(config.oidc.keys?.private && config.oidc.keys?.public)) {
logger.fatal`The OpenID keys are not set in the config`;
logger.fatal`Below are generated key for you to copy in the config at oidc.keys`;
// 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");
logger.fatal`Generated keys:`;
logger.fatal`Private key: ${chalk.gray(privateKey)}`;
logger.fatal`Public key: ${chalk.gray(publicKey)}`;
// Hang until Ctrl+C is pressed
await Bun.sleep(Number.POSITIVE_INFINITY);
}
// Try and import the key
const privateKey = await crypto.subtle
.importKey(
"pkcs8",
Buffer.from(config.oidc.keys?.private ?? "", "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.keys?.public ?? "", "base64"),
"Ed25519",
false,
["verify"],
)
.catch((e) => e as Error);
if (privateKey instanceof Error || publicKey instanceof Error) {
throw new Error(
"The OpenID keys could not be imported! You may generate a new one by removing the old ones from config and restarting the server (this will invalidate all current JWTs).",
);
}
};
const checkFederationConfig = async (config: Config) => { const checkFederationConfig = async (config: Config) => {
const logger = getLogger("server"); const logger = getLogger("server");