Skip to content

Instantly share code, notes, and snippets.

@macropygia
Last active April 2, 2024 00:13
Show Gist options
  • Save macropygia/ce45405cc225a10fead9cd873f458c85 to your computer and use it in GitHub Desktop.
Save macropygia/ce45405cc225a10fead9cd873f458c85 to your computer and use it in GitHub Desktop.
OpenID Connect RP for GitLab (ElysiaJS plugin)
import { Elysia, t, type LifeCycleType } from "elysia";
import {
Issuer,
generators,
type TokenSet,
type UnknownObject,
type IdTokenClaims,
} from "openid-client";
import loki, { type Collection } from "lokijs";
/** Session */
export interface OIDCClientSession {
/** Session ID */
sessionId: string;
/** Session Expired At (Unixtime, ms) */
sessionExpiredAt: number;
/** Session Refreshed At (Unixtime, ms) */
sessionRefreshedAt: number;
/**
OIDC Code Verifier for PKCE
- Deleted after use
- この値の有無でログインかリフレッシュか判断が可能
*/
codeVerifier?: string;
/** OIDC Refresh Token */
refreshToken?: string;
/** OIDC sub Claim */
sub?: string;
/** OIDC ID Token @ignore Reserved */
idToken?: string;
/** OIDC Access Token @ignore Reserved */
accessToken?: string;
/** OIDC ID Token Claims @ignore Reserved */
claims?: IdTokenClaims;
/** OIDC User Info @ignore Reserved */
userInfo?: UnknownObject;
}
/** Options */
export interface OIDCClientOptions {
/** OpenID Provider @example "https://gitlab.com" */
issuerUrl: string;
/** Client ID */
clientId: string;
/** Client secret */
clientSecret: string;
/** Application base url @example "https://example.com" */
baseUrl: string;
/**
Path to redirect after callback is complete (use with baseUrl)
@default "/"
@example "/path/to/app"
*/
completedPath?: string;
/** Database file path @default "sessions.db" @example "/path/to/sessions.db" */
dbFile?: string;
/** Path prefix @default "/auth" */
pathPrefix?: string;
/** Login path (GET) @default "/login" */
loginPath?: string;
/** Callback path (GET) @default "/callback" */
callbackPath?: string;
/** Info path (POST) @default "/info" */
infoPath?: string;
/** Login expiration (ms) @default 600000 (10 minutes) */
loginExpiration?: number;
/** Refresh expiration (ms) @default 2592000000 (30 days) */
refreshExpiration?: number;
/** Refresh interval (ms) @default 1800000 (30 minutes) */
refreshInterval?: number;
/** Scope of onBeforeHandle @default "scoped" */
onBeforeScope?: LifeCycleType;
}
/** Cookie definition for ElysiaJS */
export const oidcClientCookieDefinition = t.Cookie({
session: t.Optional(t.String()),
});
/** OpenID Connect Client Plugin for ElysiaJS */
export const oidcClient = async ({
issuerUrl,
clientId,
clientSecret,
baseUrl,
completedPath = "/",
dbFile = "sessions.db",
pathPrefix = "/auth",
loginPath = "/login",
callbackPath = "/callback",
infoPath = "/info",
loginExpiration = 60 * 10 * 1000, // 10 minutes
refreshExpiration = 60 * 60 * 24 * 30 * 1000, // 30 days
refreshInterval = 60 * 30 * 1000, // 30 minutes
onBeforeScope = "scoped",
}: OIDCClientOptions) => {
const redirectUri = `${baseUrl}${pathPrefix}${callbackPath}`;
const redirectUris = [redirectUri];
// Init database
let sessions: Collection<OIDCClientSession>;
const db = new loki(dbFile, {
autoload: true,
autoloadCallback: () => {
sessions = db.getCollection<OIDCClientSession>("sessions");
if (!sessions) {
sessions = db.addCollection<OIDCClientSession>("sessions", {
indices: ["sessionId", "sessionExpiredAt"],
unique: ["sessionId"],
});
}
},
autosave: true,
autosaveInterval: 10000,
autosaveCallback: () => {
// Remove expired sessions
sessions
.chain()
.find({ sessionExpiredAt: { $lt: Date.now() } })
.remove();
},
});
// Discover IdP by discovery endpoint
const issuer = await Issuer.discover(issuerUrl);
// Initialize RP
const client = new issuer.Client({
client_id: clientId,
client_secret: clientSecret,
redirect_uris: redirectUris,
});
/**
* Find and validate session from cookie and DB
* @param session Cookie
* @returns Session data or false
*/
const findAndValidateSession = (
sessionId: string | undefined,
):
| (OIDCClientSession & {
refreshToken: string;
sub: string;
})
| false => {
// Existence of Cookie
if (!sessionId) {
console.error("findAndValidateSession", "!sessionId");
return false;
}
// Existence of Session in DB
const currentSession = sessions.findOne({ sessionId });
if (!currentSession) {
console.error("findAndValidateSession", "!currentSession");
return false;
}
const { sessionExpiredAt, codeVerifier, refreshToken, sub } =
currentSession;
// Check expiration
if (sessionExpiredAt < Date.now()) {
console.error("findAndValidateSession", "sessionExpiredAt < Date.now()");
deleteSession(sessionId);
return false;
}
// Check tokens and claims (if necessary)
if (!(codeVerifier || (refreshToken && sub))) {
console.error(
"findAndValidateSession",
"!(codeVerifier || (refreshToken && sub))",
);
deleteSession(sessionId);
return false;
}
return currentSession as OIDCClientSession & {
refreshToken: string;
sub: string;
};
};
/**
* Create session and insert to DB
* @returns [sessionId, authorizationUrl]
*/
const createSession = (): [string, string] => {
console.log("createSession");
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
const authorizationUrl = client.authorizationUrl({
scope: "openid",
code_challenge,
code_challenge_method: "S256",
redirect_uri: redirectUri,
});
const sessionId = generators.random();
sessions.insert({
sessionId,
sessionExpiredAt: Date.now() + loginExpiration,
sessionRefreshedAt: Date.now(),
codeVerifier: code_verifier,
});
return [sessionId, authorizationUrl];
};
/**
* Update session in DB
* @param sessionId Session ID
* @param tokenSet TokenSet
*/
const updateSession = (sessionId: string, tokenSet: TokenSet) => {
console.log("updateSession");
sessions
.chain()
.find({ sessionId: { $eq: sessionId } })
.update((obj) => {
obj.idToken = tokenSet.id_token;
obj.refreshToken = tokenSet.refresh_token;
obj.sub = tokenSet.claims().sub;
obj.codeVerifier = undefined;
obj.sessionExpiredAt = Date.now() + refreshExpiration;
obj.sessionRefreshedAt = Date.now();
});
};
/**
* Delete session from DB
* @param sessionId Session ID
*/
const deleteSession = (sessionId: string) => {
console.log("deleteSession");
sessions
.chain()
.find({ sessionId: { $eq: sessionId } })
.remove();
};
return new Elysia({
name: "@macropygia/elysia-oidc-client",
})
.guard({
cookie: oidcClientCookieDefinition,
})
.onBeforeHandle(
{ as: onBeforeScope },
async ({ cookie: { session }, set, request: { url, method } }) => {
console.log("Start onBeforeHandle", onBeforeScope);
// Ignore auth pages
if (new URL(url).pathname.startsWith(pathPrefix)) {
return;
}
// Ignore non-GET methods
if (method.toLowerCase() !== "get") {
return;
}
const currentSession = findAndValidateSession(session.value);
if (!currentSession) {
console.log("onBeforeHandle", "session does not exists");
set.redirect = `${pathPrefix}${loginPath}`;
return new Response(null, { status: 303 });
}
// DB照会結果展開
const { sessionId, codeVerifier, sessionRefreshedAt, refreshToken } =
currentSession;
// verifierは使い終わったら消えている必要がある
if (codeVerifier) {
deleteSession(sessionId);
return new Response(null, { status: 500 });
}
// セッションが有効かつ前回のリフレッシュから規定時間以内ならなにもしない
if (sessionRefreshedAt + refreshInterval > Date.now()) {
console.log("onBeforeHandle", "skip refresh");
return;
}
// セッションが有効かつ前回のリフレッシュから規定時間経過していたら更新
try {
console.log("client.refresh");
const tokenSet = await client.refresh(refreshToken);
updateSession(sessionId, tokenSet);
session.expires = new Date(Date.now() + refreshExpiration);
} catch (e: unknown) {
console.error(e);
if (e instanceof Error) {
return new Response(null, { status: 401, statusText: e.message });
}
return new Response(null, { status: 500 });
}
},
)
.group(pathPrefix, (app) =>
app
.get(loginPath, ({ cookie: { session }, set }) => {
console.log("Start login process");
const [sessionId, authorizationUrl] = createSession();
session.value = sessionId;
session.httpOnly = true;
session.secure = true;
session.path = "/";
session.expires = new Date(Date.now() + loginExpiration);
set.status = 303;
set.redirect = authorizationUrl;
})
.get(callbackPath, async ({ cookie: { session }, set, request }) => {
console.log("Start callback process");
// session状態確認
const currentSession = findAndValidateSession(session.value);
if (!currentSession) {
return new Response("Invalid session", {
status: 400,
statusText: "Invalid session",
});
}
// code_verifier有無確認
const { sessionId, codeVerifier } = currentSession;
if (!codeVerifier) {
return new Response("Verifier missing", {
status: 400,
statusText: "Verifier missing",
});
}
// URLからqueryパラメータ取得
console.log("client.callbackParams");
const params = client.callbackParams(request.url);
// コールバック検証
try {
console.log("client.callback");
const tokenSet = await client.callback(redirectUri, params, {
code_verifier: codeVerifier,
});
updateSession(sessionId, tokenSet);
session.expires = new Date(Date.now() + refreshExpiration);
} catch (e: unknown) {
if (e instanceof Error) {
return new Response(null, { status: 401, statusText: e.message });
}
return new Response(null, { status: 500 });
}
set.redirect = completedPath;
})
.post(
infoPath,
({ body: { sessionId } }: { body: { sessionId: string } }) => {
// session状態確認
const currentSession = findAndValidateSession(sessionId);
if (!currentSession) {
return new Response("Invalid session", { status: 401 });
}
return new Response(JSON.stringify(currentSession), {
headers: { "Content-Type": "application/json" },
});
},
)
);
};
export default oidcClient;
import { Elysia } from "elysia";
import oidcClient from "./elysia-oidc-client";
const oidc = await oidcClient({
issuerUrl: process.env.GITLAB_URL as string,
clientId: process.env.GITLAB_OAUTH_CLIENT_ID as string,
clientSecret: process.env.GITLAB_OAUTH_CLIENT_SECRET as string,
baseUrl: process.env.BASE_URL as string,
});
const app = new Elysia()
.guard((app) =>
app.use(oidc).get("/", async ({ cookie: { session } }) => {
const result = await fetch(`${process.env.BASE_URL as string}/auth/info`, {
method: "POST",
body: JSON.stringify({ sessionId: session.value }),
headers: {
"Content-Type": "application/json",
}
})
.then((res) => {
if (!res.ok) {
return `${res.status} ${res.statusText}`;
}
return res.json();
})
.catch((e) => {
if (e instanceof Error) {
return e.message;
}
return "Unknown Error";
});
return new Response(
`<html><body><pre>${JSON.stringify(
result,
null,
2,
)}</pre></body></html>`,
{
headers: { "Content-Type": "text/html" },
},
);
}),
)
.get("/outside", () => "outside")
.listen(8080);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment