my own indieAuth provider! indiko.dunkirk.sh/docs
indieauth oauth2-server
at main 167 lines 3.9 kB view raw
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}