Skip to content

Commit e416b4d

Browse files
committed
feat(identities): signed oauth state helper for link flow
1 parent fcc4a8b commit e416b4d

7 files changed

Lines changed: 3222 additions & 0 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, test, expect } from "bun:test"
2+
import { signIdentityState, verifyIdentityState } from "./identity-oauth-state"
3+
4+
const SECRET = "test-secret-abcdef"
5+
6+
describe("identity oauth state", () => {
7+
test("round-trips a userId", () => {
8+
const state = signIdentityState({ userId: "u-123", secret: SECRET, ttlSeconds: 600 })
9+
expect(verifyIdentityState({ state, secret: SECRET })).toEqual({ userId: "u-123" })
10+
})
11+
12+
test("rejects tampered state", () => {
13+
const state = signIdentityState({ userId: "u-123", secret: SECRET, ttlSeconds: 600 })
14+
// Flip a character in the middle of the string — reliably changes decoded bytes
15+
const mid = Math.floor(state.length / 2)
16+
const bad = state.slice(0, mid) + (state[mid] === "a" ? "b" : "a") + state.slice(mid + 1)
17+
expect(() => verifyIdentityState({ state: bad, secret: SECRET })).toThrow()
18+
})
19+
20+
test("rejects expired state", () => {
21+
const state = signIdentityState({ userId: "u-123", secret: SECRET, ttlSeconds: -1 })
22+
expect(() => verifyIdentityState({ state, secret: SECRET })).toThrow(/expired/i)
23+
})
24+
25+
test("rejects state signed with a different secret", () => {
26+
const state = signIdentityState({ userId: "u-123", secret: SECRET, ttlSeconds: 600 })
27+
expect(() => verifyIdentityState({ state, secret: "other-secret" })).toThrow()
28+
})
29+
})
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { createHmac, timingSafeEqual } from "node:crypto"
2+
3+
type SignInput = { userId: string; secret: string; ttlSeconds: number }
4+
5+
export function signIdentityState({ userId, secret, ttlSeconds }: SignInput): string {
6+
const expiresAt = Math.floor(Date.now() / 1000) + ttlSeconds
7+
const nonce = crypto.randomUUID()
8+
const payload = `${userId}.${expiresAt}.${nonce}`
9+
const sig = createHmac("sha256", secret).update(payload).digest("hex")
10+
return Buffer.from(`${payload}.${sig}`).toString("base64url")
11+
}
12+
13+
export function verifyIdentityState({ state, secret }: { state: string; secret: string }): {
14+
userId: string
15+
} {
16+
const decoded = Buffer.from(state, "base64url").toString("utf8")
17+
const parts = decoded.split(".")
18+
if (parts.length !== 4) throw new Error("malformed state")
19+
const [userId, expiresAtStr, nonce, sig] = parts
20+
const expected = createHmac("sha256", secret)
21+
.update(`${userId}.${expiresAtStr}.${nonce}`)
22+
.digest("hex")
23+
const a = Buffer.from(sig, "hex")
24+
const b = Buffer.from(expected, "hex")
25+
if (a.length !== b.length || !timingSafeEqual(a, b)) throw new Error("bad signature")
26+
const expiresAt = Number(expiresAtStr)
27+
if (!Number.isFinite(expiresAt)) throw new Error("malformed state")
28+
if (expiresAt < Math.floor(Date.now() / 1000)) throw new Error("state expired")
29+
return { userId }
30+
}
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
-- Current sql file was generated after introspecting the database
2+
-- If you want to run this migration please uncomment this code before executing migrations
3+
/*
4+
CREATE TYPE "public"."github_write_lock_kind" AS ENUM('labels', 'assignees', 'milestone', 'state', 'title', 'comment_upsert', 'comment_delete');--> statement-breakpoint
5+
CREATE TYPE "public"."identity_provider" AS ENUM('github');--> statement-breakpoint
6+
CREATE TYPE "public"."report_comment_source" AS ENUM('dashboard', 'github');--> statement-breakpoint
7+
CREATE TABLE "app_settings" (
8+
"id" integer PRIMARY KEY DEFAULT 1 NOT NULL,
9+
"signup_gated" boolean DEFAULT false NOT NULL,
10+
"allowed_email_domains" text[] DEFAULT '{""}' NOT NULL,
11+
"updated_at" timestamp DEFAULT now() NOT NULL,
12+
CONSTRAINT "app_settings_singleton" CHECK (id = 1)
13+
);
14+
--> statement-breakpoint
15+
CREATE TABLE "github_app" (
16+
"id" integer PRIMARY KEY DEFAULT 1 NOT NULL,
17+
"app_id" text NOT NULL,
18+
"slug" text NOT NULL,
19+
"private_key" text NOT NULL,
20+
"webhook_secret" text NOT NULL,
21+
"client_id" text NOT NULL,
22+
"client_secret" text NOT NULL,
23+
"html_url" text NOT NULL,
24+
"created_by" text NOT NULL,
25+
"created_at" timestamp DEFAULT now() NOT NULL,
26+
"updated_at" timestamp DEFAULT now() NOT NULL,
27+
CONSTRAINT "github_app_singleton" CHECK (id = 1)
28+
);
29+
--> statement-breakpoint
30+
CREATE TABLE "user" (
31+
"id" text PRIMARY KEY NOT NULL,
32+
"name" text NOT NULL,
33+
"email" text NOT NULL,
34+
"email_verified" boolean DEFAULT false NOT NULL,
35+
"image" text,
36+
"created_at" timestamp NOT NULL,
37+
"updated_at" timestamp NOT NULL,
38+
"role" text DEFAULT 'member',
39+
"status" text DEFAULT 'active',
40+
CONSTRAINT "user_email_unique" UNIQUE("email")
41+
);
42+
--> statement-breakpoint
43+
CREATE TABLE "projects" (
44+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
45+
"name" varchar(120) NOT NULL,
46+
"created_by" text NOT NULL,
47+
"public_key" text,
48+
"allowed_origins" text[] DEFAULT '{""}' NOT NULL,
49+
"daily_report_cap" integer DEFAULT 1000 NOT NULL,
50+
"replay_enabled" boolean DEFAULT true NOT NULL,
51+
"public_key_regenerated_at" timestamp DEFAULT now() NOT NULL,
52+
"created_at" timestamp DEFAULT now() NOT NULL,
53+
"updated_at" timestamp DEFAULT now() NOT NULL,
54+
"deleted_at" timestamp
55+
);
56+
--> statement-breakpoint
57+
CREATE TABLE "account" (
58+
"id" text PRIMARY KEY NOT NULL,
59+
"account_id" text NOT NULL,
60+
"provider_id" text NOT NULL,
61+
"user_id" text NOT NULL,
62+
"access_token" text,
63+
"refresh_token" text,
64+
"id_token" text,
65+
"access_token_expires_at" timestamp,
66+
"refresh_token_expires_at" timestamp,
67+
"scope" text,
68+
"password" text,
69+
"created_at" timestamp NOT NULL,
70+
"updated_at" timestamp NOT NULL
71+
);
72+
--> statement-breakpoint
73+
CREATE TABLE "session" (
74+
"id" text PRIMARY KEY NOT NULL,
75+
"expires_at" timestamp NOT NULL,
76+
"token" text NOT NULL,
77+
"created_at" timestamp NOT NULL,
78+
"updated_at" timestamp NOT NULL,
79+
"ip_address" text,
80+
"user_agent" text,
81+
"user_id" text NOT NULL,
82+
CONSTRAINT "session_token_unique" UNIQUE("token")
83+
);
84+
--> statement-breakpoint
85+
CREATE TABLE "project_invitations" (
86+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
87+
"project_id" uuid NOT NULL,
88+
"email" text NOT NULL,
89+
"role" text NOT NULL,
90+
"token" text NOT NULL,
91+
"status" text DEFAULT 'pending' NOT NULL,
92+
"invited_by" text NOT NULL,
93+
"expires_at" timestamp NOT NULL,
94+
"created_at" timestamp DEFAULT now() NOT NULL,
95+
"accepted_at" timestamp,
96+
"accepted_by" text
97+
);
98+
--> statement-breakpoint
99+
CREATE TABLE "verification" (
100+
"id" text PRIMARY KEY NOT NULL,
101+
"identifier" text NOT NULL,
102+
"value" text NOT NULL,
103+
"expires_at" timestamp NOT NULL,
104+
"created_at" timestamp NOT NULL,
105+
"updated_at" timestamp NOT NULL
106+
);
107+
--> statement-breakpoint
108+
CREATE TABLE "reports" (
109+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
110+
"project_id" uuid NOT NULL,
111+
"title" text NOT NULL,
112+
"description" text,
113+
"context" jsonb DEFAULT '{}'::jsonb NOT NULL,
114+
"origin" text,
115+
"ip" text,
116+
"status" text DEFAULT 'open' NOT NULL,
117+
"assignee_id" text,
118+
"priority" text DEFAULT 'normal' NOT NULL,
119+
"tags" text[] DEFAULT '{""}' NOT NULL,
120+
"source" text DEFAULT 'web' NOT NULL,
121+
"device_platform" text,
122+
"idempotency_key" text,
123+
"created_at" timestamp DEFAULT now() NOT NULL,
124+
"updated_at" timestamp DEFAULT now() NOT NULL,
125+
"github_issue_number" integer,
126+
"github_issue_node_id" text,
127+
"github_issue_url" text,
128+
"milestone_number" integer,
129+
"milestone_title" text,
130+
"github_synced_at" timestamp with time zone,
131+
"github_comments_synced_at" timestamp with time zone
132+
);
133+
--> statement-breakpoint
134+
CREATE TABLE "report_events" (
135+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
136+
"report_id" uuid NOT NULL,
137+
"project_id" uuid NOT NULL,
138+
"actor_id" text,
139+
"kind" text NOT NULL,
140+
"payload" jsonb DEFAULT '{}'::jsonb NOT NULL,
141+
"created_at" timestamp DEFAULT now() NOT NULL
142+
);
143+
--> statement-breakpoint
144+
CREATE TABLE "rate_limit_buckets" (
145+
"key" text PRIMARY KEY NOT NULL,
146+
"tokens" real NOT NULL,
147+
"last_refill_ms" bigint NOT NULL
148+
);
149+
--> statement-breakpoint
150+
CREATE TABLE "github_integrations" (
151+
"project_id" uuid PRIMARY KEY NOT NULL,
152+
"installation_id" bigint NOT NULL,
153+
"repo_owner" text DEFAULT '' NOT NULL,
154+
"repo_name" text DEFAULT '' NOT NULL,
155+
"default_labels" text[] DEFAULT '{""}' NOT NULL,
156+
"default_assignees" text[] DEFAULT '{""}' NOT NULL,
157+
"status" text DEFAULT 'connected' NOT NULL,
158+
"last_error" text,
159+
"connected_by" text,
160+
"connected_at" timestamp DEFAULT now() NOT NULL,
161+
"updated_at" timestamp DEFAULT now() NOT NULL,
162+
"auto_create_on_intake" boolean DEFAULT false NOT NULL,
163+
"push_on_edit" boolean DEFAULT false NOT NULL,
164+
"labels_last_synced_at" timestamp with time zone,
165+
"milestones_last_synced_at" timestamp with time zone,
166+
"members_last_synced_at" timestamp with time zone
167+
);
168+
--> statement-breakpoint
169+
CREATE TABLE "report_sync_jobs" (
170+
"report_id" uuid PRIMARY KEY NOT NULL,
171+
"state" text DEFAULT 'pending' NOT NULL,
172+
"attempts" integer DEFAULT 0 NOT NULL,
173+
"last_error" text,
174+
"next_attempt_at" timestamp DEFAULT now() NOT NULL,
175+
"created_at" timestamp DEFAULT now() NOT NULL,
176+
"updated_at" timestamp DEFAULT now() NOT NULL
177+
);
178+
--> statement-breakpoint
179+
CREATE TABLE "report_attachments" (
180+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
181+
"report_id" uuid NOT NULL,
182+
"kind" text NOT NULL,
183+
"storage_key" text NOT NULL,
184+
"content_type" text NOT NULL,
185+
"size_bytes" integer NOT NULL,
186+
"created_at" timestamp DEFAULT now() NOT NULL,
187+
CONSTRAINT "report_attachments_kind_check" CHECK (kind = ANY (ARRAY['screenshot'::text, 'annotated-screenshot'::text, 'replay'::text, 'logs'::text]))
188+
);
189+
--> statement-breakpoint
190+
CREATE TABLE "github_write_locks" (
191+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
192+
"report_id" uuid NOT NULL,
193+
"kind" "github_write_lock_kind" NOT NULL,
194+
"signature" text NOT NULL,
195+
"expires_at" timestamp with time zone NOT NULL
196+
);
197+
--> statement-breakpoint
198+
CREATE TABLE "github_webhook_deliveries" (
199+
"delivery_id" text PRIMARY KEY NOT NULL,
200+
"received_at" timestamp with time zone DEFAULT now() NOT NULL
201+
);
202+
--> statement-breakpoint
203+
CREATE TABLE "report_comments" (
204+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
205+
"report_id" uuid NOT NULL,
206+
"user_id" text,
207+
"github_login" text,
208+
"body" text NOT NULL,
209+
"github_comment_id" bigint,
210+
"source" "report_comment_source" NOT NULL,
211+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
212+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
213+
"deleted_at" timestamp with time zone,
214+
CONSTRAINT "report_comments_github_comment_id_unique" UNIQUE("github_comment_id")
215+
);
216+
--> statement-breakpoint
217+
CREATE TABLE "user_identities" (
218+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
219+
"user_id" text NOT NULL,
220+
"provider" "identity_provider" NOT NULL,
221+
"external_id" text NOT NULL,
222+
"external_handle" text NOT NULL,
223+
"external_name" text,
224+
"external_email" text,
225+
"external_avatar_url" text,
226+
"linked_at" timestamp with time zone DEFAULT now() NOT NULL,
227+
"last_verified_at" timestamp with time zone DEFAULT now() NOT NULL
228+
);
229+
--> statement-breakpoint
230+
CREATE TABLE "project_members" (
231+
"project_id" uuid NOT NULL,
232+
"user_id" text NOT NULL,
233+
"role" text NOT NULL,
234+
"invited_by" text,
235+
"joined_at" timestamp DEFAULT now() NOT NULL,
236+
CONSTRAINT "project_members_project_id_user_id_pk" PRIMARY KEY("project_id","user_id")
237+
);
238+
--> statement-breakpoint
239+
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
240+
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
241+
ALTER TABLE "project_invitations" ADD CONSTRAINT "project_invitations_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
242+
ALTER TABLE "reports" ADD CONSTRAINT "reports_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
243+
ALTER TABLE "reports" ADD CONSTRAINT "reports_assignee_id_user_id_fk" FOREIGN KEY ("assignee_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
244+
ALTER TABLE "report_events" ADD CONSTRAINT "report_events_report_id_reports_id_fk" FOREIGN KEY ("report_id") REFERENCES "public"."reports"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
245+
ALTER TABLE "report_events" ADD CONSTRAINT "report_events_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
246+
ALTER TABLE "github_integrations" ADD CONSTRAINT "github_integrations_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
247+
ALTER TABLE "report_sync_jobs" ADD CONSTRAINT "report_sync_jobs_report_id_reports_id_fk" FOREIGN KEY ("report_id") REFERENCES "public"."reports"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
248+
ALTER TABLE "report_attachments" ADD CONSTRAINT "report_attachments_report_id_reports_id_fk" FOREIGN KEY ("report_id") REFERENCES "public"."reports"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
249+
ALTER TABLE "github_write_locks" ADD CONSTRAINT "github_write_locks_report_id_reports_id_fk" FOREIGN KEY ("report_id") REFERENCES "public"."reports"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
250+
ALTER TABLE "report_comments" ADD CONSTRAINT "report_comments_report_id_reports_id_fk" FOREIGN KEY ("report_id") REFERENCES "public"."reports"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
251+
ALTER TABLE "report_comments" ADD CONSTRAINT "report_comments_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
252+
ALTER TABLE "user_identities" ADD CONSTRAINT "user_identities_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
253+
ALTER TABLE "project_members" ADD CONSTRAINT "project_members_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
254+
CREATE UNIQUE INDEX "projects_public_key_idx" ON "projects" USING btree ("public_key" text_ops);--> statement-breakpoint
255+
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id" text_ops);--> statement-breakpoint
256+
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id" text_ops);--> statement-breakpoint
257+
CREATE INDEX "project_invitations_project_email_idx" ON "project_invitations" USING btree ("project_id" text_ops,"email" text_ops);--> statement-breakpoint
258+
CREATE UNIQUE INDEX "project_invitations_token_idx" ON "project_invitations" USING btree ("token" text_ops);--> statement-breakpoint
259+
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier" text_ops);--> statement-breakpoint
260+
CREATE INDEX "reports_github_issue_number_idx" ON "reports" USING btree ("github_issue_number" int4_ops) WHERE (github_issue_number IS NOT NULL);--> statement-breakpoint
261+
CREATE INDEX "reports_project_assignee_idx" ON "reports" USING btree ("project_id" uuid_ops,"assignee_id" text_ops);--> statement-breakpoint
262+
CREATE INDEX "reports_project_created_idx" ON "reports" USING btree ("project_id" uuid_ops,"created_at" timestamp_ops);--> statement-breakpoint
263+
CREATE INDEX "reports_project_idempotency_key_idx" ON "reports" USING btree ("project_id" text_ops,"idempotency_key" text_ops) WHERE (idempotency_key IS NOT NULL);--> statement-breakpoint
264+
CREATE INDEX "reports_project_priority_idx" ON "reports" USING btree ("project_id" text_ops,"priority" uuid_ops);--> statement-breakpoint
265+
CREATE INDEX "reports_project_source_created_idx" ON "reports" USING btree ("project_id" timestamp_ops,"source" text_ops,"created_at" uuid_ops);--> statement-breakpoint
266+
CREATE INDEX "reports_project_status_created_idx" ON "reports" USING btree ("project_id" timestamp_ops,"status" timestamp_ops,"created_at" timestamp_ops);--> statement-breakpoint
267+
CREATE INDEX "reports_project_updated_at_idx" ON "reports" USING btree ("project_id" timestamp_ops,"updated_at" uuid_ops);--> statement-breakpoint
268+
CREATE INDEX "reports_tags_gin_idx" ON "reports" USING gin ("tags" array_ops);--> statement-breakpoint
269+
CREATE INDEX "report_events_project_created_at_idx" ON "report_events" USING btree ("project_id" timestamp_ops,"created_at" timestamp_ops);--> statement-breakpoint
270+
CREATE INDEX "report_events_report_created_idx" ON "report_events" USING btree ("report_id" timestamp_ops,"created_at" timestamp_ops);--> statement-breakpoint
271+
CREATE INDEX "github_integrations_installation_id_idx" ON "github_integrations" USING btree ("installation_id" int8_ops);--> statement-breakpoint
272+
CREATE INDEX "report_sync_jobs_failed_idx" ON "report_sync_jobs" USING btree ("state" text_ops) WHERE (state = 'failed'::text);--> statement-breakpoint
273+
CREATE INDEX "report_sync_jobs_pending_idx" ON "report_sync_jobs" USING btree ("next_attempt_at" timestamp_ops) WHERE (state = 'pending'::text);--> statement-breakpoint
274+
CREATE INDEX "report_attachments_report_idx" ON "report_attachments" USING btree ("report_id" uuid_ops);--> statement-breakpoint
275+
CREATE INDEX "github_write_locks_lookup_idx" ON "github_write_locks" USING btree ("report_id" timestamptz_ops,"kind" enum_ops,"expires_at" enum_ops);--> statement-breakpoint
276+
CREATE INDEX "github_webhook_deliveries_received_at_idx" ON "github_webhook_deliveries" USING btree ("received_at" timestamptz_ops);--> statement-breakpoint
277+
CREATE INDEX "report_comments_report_created_idx" ON "report_comments" USING btree ("report_id" timestamptz_ops,"created_at" timestamptz_ops);--> statement-breakpoint
278+
CREATE UNIQUE INDEX "user_identities_provider_external_id_unique" ON "user_identities" USING btree ("provider" text_ops,"external_id" text_ops);--> statement-breakpoint
279+
CREATE UNIQUE INDEX "user_identities_user_provider_unique" ON "user_identities" USING btree ("user_id" text_ops,"provider" text_ops);--> statement-breakpoint
280+
CREATE INDEX "project_members_user_idx" ON "project_members" USING btree ("user_id" text_ops);
281+
*/

0 commit comments

Comments
 (0)