g1t/services/identity/src/index.ts

209 lines6,276 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Initial g1t: services, event bus, intents and attempts1import { WorkerEntrypoint } from "cloudflare:workers";
2
3import {
4 type AccessToken,
5 type IdentityApi,
6 type Result,
7 type SshKey,
8 type User,
9 type Viewer,
10 fail,
11 newId,
12 ok,
13} from "@g1t/contracts";
14
15import {
16 parseSshKey,
17 randomHex,
18 sha256Hex,
19 verifyPassword,
20} from "./crypto";
21
22export interface IdentityEnv {
23 DB: D1Database;
24}
25
26const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60;
27const TOKEN_PREFIX = "g1t_";
28
29type SshKeyRow = { id: string; title: string; fingerprint: string; created_at: number };
30type TokenRow = { id: string; name: string; created_at: number };
31
32function toSshKey(row: SshKeyRow): SshKey {
33 return {
34 id: row.id,
35 title: row.title,
36 fingerprint: row.fingerprint,
37 createdAt: row.created_at * 1000,
38 };
39}
40
41function toAccessToken(row: TokenRow): AccessToken {
42 return { id: row.id, name: row.name, createdAt: row.created_at * 1000 };
43}
44
45export default class IdentityService
46 extends WorkerEntrypoint<IdentityEnv>
47 implements IdentityApi
48{
49 private get db(): D1Database {
50 return this.env.DB;
51 }
52
53 /** A Worker must have an event handler; this service is RPC-only. */
54 fetch(): Response {
55 return new Response("Not found\n", { status: 404 });
56 }
57
58 private async userForPassword(username: string, password: string): Promise<Viewer> {
59 const row = await this.db
60 .prepare("SELECT id, username, password_hash FROM users WHERE username = ?")
61 .bind(username.toLowerCase())
62 .first<User & { password_hash: string }>();
63 if (!row || !(await verifyPassword(password, row.password_hash))) return null;
64 return { id: row.id, username: row.username };
65 }
66
67 async signIn(
68 username: string,
69 password: string,
70 ): Promise<Result<{ user: User; sessionToken: string }>> {
71 const user = await this.userForPassword(username, password);
72 if (!user) return fail("unauthenticated", "Incorrect username or password.");
73 const sessionToken = randomHex(32);
74 await this.db
75 .prepare(
76 "INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, unixepoch() + ?)",
77 )
78 .bind(await sha256Hex(sessionToken), user.id, SESSION_TTL_SECONDS)
79 .run();
80 return ok({ user, sessionToken });
81 }
82
83 async signOut(sessionToken: string): Promise<void> {
84 await this.db
85 .prepare("DELETE FROM sessions WHERE id = ?")
86 .bind(await sha256Hex(sessionToken))
87 .run();
88 }
89
90 async userForSession(sessionToken: string): Promise<Viewer> {
91 return this.db
92 .prepare(
93 `SELECT users.id, users.username FROM sessions
94 JOIN users ON users.id = sessions.user_id
95 WHERE sessions.id = ? AND sessions.expires_at > unixepoch()`,
96 )
97 .bind(await sha256Hex(sessionToken))
98 .first<User>();
99 }
100
101 async userForGitCredentials(username: string, secret: string): Promise<Viewer> {
102 if (!secret.startsWith(TOKEN_PREFIX)) {
103 return this.userForPassword(username, secret);
104 }
105 // Like GitHub, a token alone identifies its user.
106 return this.db
107 .prepare(
108 `SELECT users.id, users.username FROM access_tokens
109 JOIN users ON users.id = access_tokens.user_id
110 WHERE token_hash = ?`,
111 )
112 .bind(await sha256Hex(secret))
113 .first<User>();
114 }
115
116 async userForSshKey(fingerprint: string): Promise<Viewer> {
117 return this.db
118 .prepare(
119 `SELECT users.id, users.username FROM ssh_keys
120 JOIN users ON users.id = ssh_keys.user_id
121 WHERE fingerprint = ?`,
122 )
123 .bind(fingerprint)
124 .first<User>();
125 }
126
127 async userByUsername(username: string): Promise<Viewer> {
128 return this.db
129 .prepare("SELECT id, username FROM users WHERE username = ?")
130 .bind(username.toLowerCase())
131 .first<User>();
132 }
133
134 async listSshKeys(user: User): Promise<SshKey[]> {
135 const { results } = await this.db
136 .prepare(
137 "SELECT id, title, fingerprint, created_at FROM ssh_keys WHERE user_id = ? ORDER BY id",
138 )
139 .bind(user.id)
140 .all<SshKeyRow>();
141 return results.map(toSshKey);
142 }
143
144 async addSshKey(user: User, title: string, publicKey: string): Promise<Result<SshKey>> {
145 const key = await parseSshKey(publicKey);
146 if (!key) return fail("invalid", "That is not a valid OpenSSH public key.");
147 const taken = await this.db
148 .prepare("SELECT 1 FROM ssh_keys WHERE fingerprint = ?")
149 .bind(key.fingerprint)
150 .first();
151 if (taken) return fail("conflict", "That key is already registered.");
152 const row: SshKeyRow = {
153 id: newId("key"),
154 title: title.trim() || key.comment || "SSH key",
155 fingerprint: key.fingerprint,
156 created_at: Math.floor(Date.now() / 1000),
157 };
158 await this.db
159 .prepare(
160 "INSERT INTO ssh_keys (id, user_id, title, public_key, fingerprint, created_at) VALUES (?, ?, ?, ?, ?, ?)",
161 )
162 .bind(row.id, user.id, row.title, key.publicKey, row.fingerprint, row.created_at)
163 .run();
164 return ok(toSshKey(row));
165 }
166
167 async removeSshKey(user: User, id: string): Promise<void> {
168 await this.db
169 .prepare("DELETE FROM ssh_keys WHERE id = ? AND user_id = ?")
170 .bind(id, user.id)
171 .run();
172 }
173
174 async listAccessTokens(user: User): Promise<AccessToken[]> {
175 const { results } = await this.db
176 .prepare(
177 "SELECT id, name, created_at FROM access_tokens WHERE user_id = ? ORDER BY id",
178 )
179 .bind(user.id)
180 .all<TokenRow>();
181 return results.map(toAccessToken);
182 }
183
184 async createAccessToken(
185 user: User,
186 name: string,
187 ): Promise<{ token: string; info: AccessToken }> {
188 const token = TOKEN_PREFIX + randomHex(20);
189 const row: TokenRow = {
190 id: newId("tok"),
191 name: name.trim() || "Access token",
192 created_at: Math.floor(Date.now() / 1000),
193 };
194 await this.db
195 .prepare(
196 "INSERT INTO access_tokens (id, user_id, name, token_hash, created_at) VALUES (?, ?, ?, ?, ?)",
197 )
198 .bind(row.id, user.id, row.name, await sha256Hex(token), row.created_at)
199 .run();
200 return { token, info: toAccessToken(row) };
201 }
202
203 async removeAccessToken(user: User, id: string): Promise<void> {
204 await this.db
205 .prepare("DELETE FROM access_tokens WHERE id = ? AND user_id = ?")
206 .bind(id, user.id)
207 .run();
208 }
209}