-
-
Save macropygia/ce45405cc225a10fead9cd873f458c85 to your computer and use it in GitHub Desktop.
OpenID Connect RP for GitLab (ElysiaJS plugin)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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