mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 13:59:16 +01:00
Add full OpenID connect provider support
This commit is contained in:
parent
14d96ac9e6
commit
947c1f4991
47 changed files with 604 additions and 247 deletions
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
84
server/api/oauth/authorize-external/index.ts
Normal file
84
server/api/oauth/authorize-external/index.ts
Normal 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
|
||||
);
|
||||
};
|
||||
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
187
server/api/oauth/callback/[issuer]/index.ts
Normal file
187
server/api/oauth/callback/[issuer]/index.ts
Normal 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);
|
||||
};
|
||||
31
server/api/oauth/providers/index.ts
Normal file
31
server/api/oauth/providers/index.ts
Normal 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,
|
||||
}))
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue