my own indieAuth provider!
indiko.dunkirk.sh/docs
indieauth
oauth2-server
1import { exportJWK, generateKeyPair, importPKCS8, SignJWT } from "jose";
2import { db } from "./db";
3
4interface OIDCKey {
5 id: number;
6 kid: string;
7 private_key: string;
8 public_key: string;
9 is_active: number;
10 created_at: number;
11}
12
13interface JWK {
14 kty: string;
15 use: string;
16 alg: string;
17 kid: string;
18 n: string;
19 e: string;
20}
21
22async function generateAndStoreKey(): Promise<OIDCKey> {
23 const { privateKey, publicKey } = await generateKeyPair("RS256", {
24 modulusLength: 2048,
25 });
26
27 const privateKeyPem = await exportKeyToPem(privateKey);
28 const publicKeyPem = await exportKeyToPem(publicKey);
29
30 const kid = `indiko-oidc-key-${Date.now()}`;
31
32 db.query(
33 "INSERT INTO oidc_keys (kid, private_key, public_key, is_active) VALUES (?, ?, ?, 1)",
34 ).run(kid, privateKeyPem, publicKeyPem);
35
36 const key = db
37 .query("SELECT * FROM oidc_keys WHERE kid = ?")
38 .get(kid) as OIDCKey;
39
40 return key;
41}
42
43async function exportKeyToPem(key: CryptoKey): Promise<string> {
44 const format = key.type === "private" ? "pkcs8" : "spki";
45 const exported = await crypto.subtle.exportKey(format, key);
46 const base64 = Buffer.from(exported).toString("base64");
47 const type = key.type === "private" ? "PRIVATE KEY" : "PUBLIC KEY";
48
49 const lines = base64.match(/.{1,64}/g) || [];
50 return `-----BEGIN ${type}-----\n${lines.join("\n")}\n-----END ${type}-----`;
51}
52
53export async function getActiveKey(): Promise<OIDCKey> {
54 let key = db
55 .query(
56 "SELECT * FROM oidc_keys WHERE is_active = 1 ORDER BY id DESC LIMIT 1",
57 )
58 .get() as OIDCKey | undefined;
59
60 if (!key) {
61 key = await generateAndStoreKey();
62 }
63
64 return key;
65}
66
67export async function getJWKS(): Promise<{ keys: JWK[] }> {
68 const keys = db
69 .query("SELECT * FROM oidc_keys WHERE is_active = 1")
70 .all() as OIDCKey[];
71
72 const jwks: JWK[] = [];
73
74 for (const key of keys) {
75 const publicKey = await importPublicKey(key.public_key);
76 const jwk = await exportJWK(publicKey);
77
78 jwks.push({
79 kty: jwk.kty as string,
80 use: "sig",
81 alg: "RS256",
82 kid: key.kid,
83 n: jwk.n as string,
84 e: jwk.e as string,
85 });
86 }
87
88 return { keys: jwks };
89}
90
91async function importPublicKey(pem: string): Promise<CryptoKey> {
92 const pemContents = pem
93 .replace("-----BEGIN PUBLIC KEY-----", "")
94 .replace("-----END PUBLIC KEY-----", "")
95 .replace(/\n/g, "");
96
97 const binaryDer = Buffer.from(pemContents, "base64");
98
99 return await crypto.subtle.importKey(
100 "spki",
101 binaryDer,
102 { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
103 true,
104 ["verify"],
105 );
106}
107
108interface IDTokenClaims {
109 sub: string;
110 aud: string;
111 nonce?: string;
112 auth_time?: number;
113 name?: string;
114 email?: string;
115 picture?: string;
116 website?: string;
117}
118
119export async function signIDToken(
120 issuer: string,
121 claims: IDTokenClaims,
122): Promise<string> {
123 const key = await getActiveKey();
124 const privateKey = await importPKCS8(key.private_key, "RS256");
125
126 const now = Math.floor(Date.now() / 1000);
127 const expiresIn = 3600; // 1 hour
128
129 const builder = new SignJWT({
130 ...claims,
131 iss: issuer,
132 iat: now,
133 exp: now + expiresIn,
134 }).setProtectedHeader({ alg: "RS256", typ: "JWT", kid: key.kid });
135
136 return await builder.sign(privateKey);
137}
138
139export function getDiscoveryDocument(origin: string) {
140 return {
141 issuer: origin,
142 authorization_endpoint: `${origin}/auth/authorize`,
143 token_endpoint: `${origin}/auth/token`,
144 userinfo_endpoint: `${origin}/userinfo`,
145 jwks_uri: `${origin}/jwks`,
146 scopes_supported: ["openid", "profile", "email"],
147 response_types_supported: ["code"],
148 grant_types_supported: ["authorization_code", "refresh_token"],
149 subject_types_supported: ["public"],
150 id_token_signing_alg_values_supported: ["RS256"],
151 token_endpoint_auth_methods_supported: ["none", "client_secret_post"],
152 claims_supported: [
153 "sub",
154 "iss",
155 "aud",
156 "exp",
157 "iat",
158 "auth_time",
159 "nonce",
160 "name",
161 "email",
162 "picture",
163 "website",
164 ],
165 code_challenge_methods_supported: ["S256"],
166 };
167}