a control panel for my server
1import { createHash, randomBytes } from "crypto";
2import { Context } from "hono";
3import { getCookie, setCookie, deleteCookie } from "hono/cookie";
4import { sign, verify } from "hono/jwt";
5
6const INDIKO_URL = process.env.INDIKO_URL || "https://indiko.dunkirk.sh";
7const ORIGIN = process.env.ORIGIN || "http://localhost:3010";
8const CLIENT_ID = process.env.CLIENT_ID || `${ORIGIN}/`;
9const CLIENT_SECRET = process.env.CLIENT_SECRET;
10const REDIRECT_URI = process.env.REDIRECT_URI || `${ORIGIN}/auth/callback`;
11const SESSION_SECRET = process.env.SESSION_SECRET || "development-secret-change-me";
12const REQUIRED_ROLE = process.env.REQUIRED_ROLE;
13const IS_DEV = process.env.NODE_ENV !== "production";
14
15export interface SessionPayload {
16 sub: string;
17 name: string;
18 role?: string;
19 exp: number;
20 [key: string]: unknown;
21}
22
23export interface PKCEChallenge {
24 verifier: string;
25 challenge: string;
26}
27
28export function generatePKCE(): PKCEChallenge {
29 const verifier = randomBytes(32).toString("base64url");
30 const challenge = createHash("sha256").update(verifier).digest("base64url");
31 return { verifier, challenge };
32}
33
34export function generateState(): string {
35 return randomBytes(16).toString("base64url");
36}
37
38export function getAuthorizationUrl(pkce: PKCEChallenge, state: string): string {
39 const params = new URLSearchParams({
40 response_type: "code",
41 client_id: CLIENT_ID,
42 redirect_uri: REDIRECT_URI,
43 scope: "profile email",
44 state,
45 code_challenge: pkce.challenge,
46 code_challenge_method: "S256",
47 });
48 return `${INDIKO_URL}/auth/authorize?${params.toString()}`;
49}
50
51
52
53export interface TokenResponse {
54 access_token: string;
55 token_type: string;
56 expires_in: number;
57 refresh_token?: string;
58 me: string;
59 profile: {
60 name?: string;
61 email?: string;
62 photo?: string;
63 url?: string;
64 };
65 scope: string;
66 iss: string;
67 role?: string;
68}
69
70export async function exchangeCodeForTokens(
71 code: string,
72 verifier: string
73): Promise<TokenResponse> {
74 const body: Record<string, string> = {
75 grant_type: "authorization_code",
76 code,
77 redirect_uri: REDIRECT_URI,
78 client_id: CLIENT_ID,
79 code_verifier: verifier,
80 };
81
82 if (CLIENT_SECRET) {
83 body.client_secret = CLIENT_SECRET;
84 }
85
86 const response = await fetch(`${INDIKO_URL}/auth/token`, {
87 method: "POST",
88 headers: { "Content-Type": "application/x-www-form-urlencoded" },
89 body: new URLSearchParams(body),
90 });
91
92 if (!response.ok) {
93 const text = await response.text();
94 throw new Error(`Token exchange failed: ${response.status} ${text}`);
95 }
96
97 return response.json();
98}
99
100export async function createSessionFromToken(
101 c: Context,
102 tokenResponse: TokenResponse
103): Promise<void> {
104 const payload: SessionPayload = {
105 sub: tokenResponse.me,
106 name: tokenResponse.profile.name || tokenResponse.me,
107 role: tokenResponse.role,
108 exp: Math.floor(Date.now() / 1000) + 86400 * 7,
109 };
110
111 const token = await sign(payload, SESSION_SECRET);
112
113 setCookie(c, "session", token, {
114 httpOnly: true,
115 secure: !IS_DEV,
116 sameSite: "Lax",
117 path: "/",
118 maxAge: 86400 * 7,
119 });
120}
121
122export async function getSession(c: Context): Promise<SessionPayload | null> {
123 const token = getCookie(c, "session");
124 if (!token) return null;
125
126 try {
127 const payload = await verify(token, SESSION_SECRET);
128 if (typeof payload.exp === "number" && payload.exp < Date.now() / 1000) {
129 return null;
130 }
131 return payload as SessionPayload;
132 } catch {
133 return null;
134 }
135}
136
137export function clearSession(c: Context): void {
138 deleteCookie(c, "session", { path: "/" });
139}
140
141export function checkRole(session: SessionPayload): boolean {
142 if (!REQUIRED_ROLE) return true;
143 return session.role === REQUIRED_ROLE;
144}
145
146export function storePKCE(c: Context, pkce: PKCEChallenge, state: string): void {
147 setCookie(c, "pkce_verifier", pkce.verifier, {
148 httpOnly: true,
149 secure: !IS_DEV,
150 sameSite: "Lax",
151 path: "/",
152 maxAge: 600,
153 });
154 setCookie(c, "oauth_state", state, {
155 httpOnly: true,
156 secure: !IS_DEV,
157 sameSite: "Lax",
158 path: "/",
159 maxAge: 600,
160 });
161}
162
163export function getPKCE(c: Context): { verifier: string | undefined; state: string | undefined } {
164 return {
165 verifier: getCookie(c, "pkce_verifier"),
166 state: getCookie(c, "oauth_state"),
167 };
168}
169
170export function clearPKCE(c: Context): void {
171 deleteCookie(c, "pkce_verifier", { path: "/" });
172 deleteCookie(c, "oauth_state", { path: "/" });
173}