Add full OpenID connect provider support

This commit is contained in:
Jesse Wierzbinski 2023-12-06 12:10:22 -10:00
parent 14d96ac9e6
commit 947c1f4991
No known key found for this signature in database
47 changed files with 604 additions and 247 deletions

View file

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

View file

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

View file

@ -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<Response> => {
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}}", `<style>${await css.text()}</style>`),
{
headers: {
"Content-Type": "text/html",
},
}
);
};

View file

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

View file

@ -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<Response> => {
const config = getConfig();
return jsonResponse(
config.oidc.providers.map(p => ({
name: p.name,
icon: p.icon,
id: p.id,
}))
);
};