Skip to content

Commit 7052b5f

Browse files
committed
fix cli oauth login polling
1 parent 1c56ed2 commit 7052b5f

18 files changed

Lines changed: 3662 additions & 267 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import db from '@codebuff/internal/db'
2+
import * as schema from '@codebuff/internal/db/schema'
3+
import { and, eq, gt } from 'drizzle-orm'
4+
5+
export interface LoginStatusUser {
6+
id: string
7+
email: string | null
8+
name: string | null
9+
authToken: string
10+
}
11+
12+
export interface LoginStatusDb {
13+
getCliSessionForAuth(
14+
fingerprintId: string,
15+
fingerprintHash: string,
16+
): Promise<LoginStatusUser | null>
17+
}
18+
19+
export function createLoginStatusDb(): LoginStatusDb {
20+
return {
21+
getCliSessionForAuth: async (fingerprintId, fingerprintHash) => {
22+
const users = await db
23+
.select({
24+
id: schema.user.id,
25+
email: schema.user.email,
26+
name: schema.user.name,
27+
authToken: schema.session.sessionToken,
28+
})
29+
.from(schema.session)
30+
.innerJoin(schema.user, eq(schema.session.userId, schema.user.id))
31+
.where(
32+
and(
33+
eq(schema.session.fingerprint_id, fingerprintId),
34+
eq(schema.session.cli_auth_hash, fingerprintHash),
35+
eq(schema.session.type, 'cli'),
36+
gt(schema.session.expires, new Date()),
37+
),
38+
)
39+
.limit(1)
40+
41+
return users[0] ?? null
42+
},
43+
}
44+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { genAuthCode } from '@codebuff/common/util/credentials'
2+
import { NextResponse } from 'next/server'
3+
import { z } from 'zod/v4'
4+
5+
import type { LoginStatusDb } from './_db'
6+
import type { Logger } from '@codebuff/common/types/contracts/logger'
7+
8+
export type { LoginStatusDb } from './_db'
9+
10+
interface GetLoginStatusDeps {
11+
req: Request
12+
db: LoginStatusDb
13+
logger: Logger
14+
secret: string
15+
now?: () => number
16+
}
17+
18+
const reqSchema = z.object({
19+
fingerprintId: z.string(),
20+
fingerprintHash: z.string(),
21+
expiresAt: z.coerce.number().finite().int().positive(),
22+
})
23+
24+
export async function getLoginStatus({
25+
req,
26+
db,
27+
logger,
28+
secret,
29+
now = Date.now,
30+
}: GetLoginStatusDeps): Promise<NextResponse> {
31+
const { searchParams } = new URL(req.url)
32+
const result = reqSchema.safeParse({
33+
fingerprintId: searchParams.get('fingerprintId'),
34+
fingerprintHash: searchParams.get('fingerprintHash'),
35+
expiresAt: searchParams.get('expiresAt'),
36+
})
37+
if (!result.success) {
38+
return NextResponse.json(
39+
{ error: 'Invalid query parameters' },
40+
{ status: 400 },
41+
)
42+
}
43+
44+
const { fingerprintId, fingerprintHash, expiresAt } = result.data
45+
46+
if (now() > expiresAt) {
47+
logger.info(
48+
{ fingerprintId, fingerprintHash, expiresAt },
49+
'Auth code expired',
50+
)
51+
return NextResponse.json(
52+
{ error: 'Authentication failed' },
53+
{ status: 401 },
54+
)
55+
}
56+
57+
const expectedHash = genAuthCode(fingerprintId, expiresAt.toString(), secret)
58+
if (fingerprintHash !== expectedHash) {
59+
logger.info(
60+
{ fingerprintId, fingerprintHash, expectedHash },
61+
'Invalid auth code',
62+
)
63+
return NextResponse.json(
64+
{ error: 'Authentication failed' },
65+
{ status: 401 },
66+
)
67+
}
68+
69+
try {
70+
const user = await db.getCliSessionForAuth(fingerprintId, fingerprintHash)
71+
72+
if (!user) {
73+
logger.info(
74+
{ fingerprintId, fingerprintHash },
75+
'No active CLI session found for login auth code',
76+
)
77+
return NextResponse.json(
78+
{ error: 'Authentication failed' },
79+
{ status: 401 },
80+
)
81+
}
82+
83+
return NextResponse.json({
84+
user: {
85+
id: user.id,
86+
name: user.name,
87+
email: user.email,
88+
authToken: user.authToken,
89+
fingerprintId,
90+
fingerprintHash,
91+
},
92+
message: 'Authentication successful!',
93+
})
94+
} catch (error) {
95+
logger.error({ error }, 'Error checking login status')
96+
return NextResponse.json(
97+
{ error: 'Internal server error' },
98+
{ status: 500 },
99+
)
100+
}
101+
}
Lines changed: 7 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,114 +1,14 @@
1-
import { genAuthCode } from '@codebuff/common/util/credentials'
2-
import db from '@codebuff/internal/db'
3-
import * as schema from '@codebuff/internal/db/schema'
41
import { env } from '@codebuff/internal/env'
5-
import { and, eq, gt, or, isNull } from 'drizzle-orm'
6-
import { NextResponse } from 'next/server'
7-
import { z } from 'zod/v4'
82

3+
import { createLoginStatusDb } from './_db'
4+
import { getLoginStatus } from './_get'
95
import { logger } from '@/util/logger'
106

117
export async function GET(req: Request) {
12-
const { searchParams } = new URL(req.url)
13-
const reqSchema = z.object({
14-
fingerprintId: z.string(),
15-
fingerprintHash: z.string(),
16-
expiresAt: z.string().transform(Number),
8+
return getLoginStatus({
9+
req,
10+
db: createLoginStatusDb(),
11+
logger,
12+
secret: env.NEXTAUTH_SECRET,
1713
})
18-
const result = reqSchema.safeParse({
19-
fingerprintId: searchParams.get('fingerprintId'),
20-
fingerprintHash: searchParams.get('fingerprintHash'),
21-
expiresAt: searchParams.get('expiresAt'),
22-
})
23-
if (!result.success) {
24-
return NextResponse.json(
25-
{ error: 'Invalid query parameters' },
26-
{ status: 400 },
27-
)
28-
}
29-
30-
const { fingerprintId, fingerprintHash, expiresAt } = result.data
31-
32-
if (Date.now() > expiresAt) {
33-
logger.info(
34-
{ fingerprintId, fingerprintHash, expiresAt },
35-
'Auth code expired',
36-
)
37-
return NextResponse.json(
38-
{ error: 'Authentication failed' },
39-
{ status: 401 },
40-
)
41-
}
42-
43-
const expectedHash = genAuthCode(
44-
fingerprintId,
45-
expiresAt.toString(),
46-
env.NEXTAUTH_SECRET,
47-
)
48-
if (fingerprintHash !== expectedHash) {
49-
logger.info(
50-
{ fingerprintId, fingerprintHash, expectedHash },
51-
'Invalid auth code',
52-
)
53-
return NextResponse.json(
54-
{ error: 'Authentication failed' },
55-
{ status: 401 },
56-
)
57-
}
58-
59-
try {
60-
const users = await db
61-
.select({
62-
id: schema.user.id,
63-
email: schema.user.email,
64-
name: schema.user.name,
65-
authToken: schema.session.sessionToken,
66-
})
67-
.from(schema.user)
68-
.leftJoin(schema.session, eq(schema.user.id, schema.session.userId))
69-
.leftJoin(
70-
schema.fingerprint,
71-
eq(schema.session.fingerprint_id, schema.fingerprint.id),
72-
)
73-
.where(
74-
and(
75-
eq(schema.session.fingerprint_id, fingerprintId),
76-
or(
77-
eq(schema.fingerprint.sig_hash, fingerprintHash),
78-
isNull(schema.fingerprint.sig_hash),
79-
),
80-
gt(schema.session.expires, new Date()),
81-
),
82-
)
83-
84-
if (users.length === 0) {
85-
logger.info(
86-
{ fingerprintId, fingerprintHash },
87-
'No active session found or fingerprint claimed by another user',
88-
)
89-
return NextResponse.json(
90-
{ error: 'Authentication failed' },
91-
{ status: 401 },
92-
)
93-
}
94-
95-
const user = users[0]
96-
return NextResponse.json({
97-
user: {
98-
id: user.id,
99-
name: user.name,
100-
email: user.email,
101-
authToken: user.authToken,
102-
fingerprintId,
103-
fingerprintHash,
104-
},
105-
message: 'Authentication successful!',
106-
})
107-
} catch (error) {
108-
logger.error({ error }, 'Error checking login status')
109-
return NextResponse.json(
110-
{ error: 'Internal server error' },
111-
{ status: 500 },
112-
)
113-
}
11414
}

freebuff/web/src/app/onboard/_db.ts

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { MAX_DATE } from '@codebuff/common/old-constants'
22
import { db } from '@codebuff/internal/db'
33
import * as schema from '@codebuff/internal/db/schema'
4-
import { and, eq, gt, isNull } from 'drizzle-orm'
4+
import { and, eq, gt, isNull, ne } from 'drizzle-orm'
55
import { cookies } from 'next/headers'
66

77
import { logger } from '@/util/logger'
@@ -12,22 +12,19 @@ type DbTransaction = Parameters<typeof db.transaction>[0] extends (
1212
? T
1313
: never
1414

15-
export async function checkReplayAttack(
15+
export async function hasCliSessionForAuthHash(
1616
fingerprintHash: string,
1717
userId: string,
1818
): Promise<boolean> {
1919
const existing = await db
20-
.select({ id: schema.user.id })
21-
.from(schema.user)
22-
.leftJoin(schema.session, eq(schema.user.id, schema.session.userId))
23-
.leftJoin(
24-
schema.fingerprint,
25-
eq(schema.session.fingerprint_id, schema.fingerprint.id),
26-
)
20+
.select({ id: schema.session.userId })
21+
.from(schema.session)
2722
.where(
2823
and(
29-
eq(schema.fingerprint.sig_hash, fingerprintHash),
30-
eq(schema.user.id, userId),
24+
eq(schema.session.cli_auth_hash, fingerprintHash),
25+
eq(schema.session.userId, userId),
26+
eq(schema.session.type, 'cli'),
27+
gt(schema.session.expires, new Date()),
3128
),
3229
)
3330
.limit(1)
@@ -42,19 +39,19 @@ export async function checkFingerprintConflict(
4239
const existingSession = await db
4340
.select({
4441
userId: schema.session.userId,
45-
expires: schema.session.expires,
4642
})
4743
.from(schema.session)
4844
.where(
4945
and(
5046
eq(schema.session.fingerprint_id, fingerprintId),
47+
ne(schema.session.userId, userId),
5148
gt(schema.session.expires, new Date()),
5249
),
5350
)
5451
.limit(1)
5552

5653
const activeSession = existingSession[0]
57-
if (activeSession && activeSession.userId !== userId) {
54+
if (activeSession) {
5855
return { hasConflict: true, existingUserId: activeSession.userId }
5956
}
6057
return { hasConflict: false }
@@ -80,7 +77,7 @@ export async function createCliSession(
8077
return db.transaction(async (tx: DbTransaction) => {
8178
await tx
8279
.insert(schema.fingerprint)
83-
.values({ sig_hash: fingerprintHash, id: fingerprintId })
80+
.values({ id: fingerprintId })
8481
.onConflictDoNothing()
8582

8683
const session = await tx
@@ -90,6 +87,7 @@ export async function createCliSession(
9087
userId,
9188
expires: MAX_DATE,
9289
fingerprint_id: fingerprintId,
90+
cli_auth_hash: fingerprintHash,
9391
type: 'cli',
9492
})
9593
.returning({ userId: schema.session.userId })

freebuff/web/src/app/onboard/_helpers.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@ export function validateAuthCode(
2020
}
2121

2222
export function isAuthCodeExpired(expiresAt: string): boolean {
23-
return expiresAt < Date.now().toString()
23+
const expiresAtMs = Number(expiresAt)
24+
return !Number.isFinite(expiresAtMs) || expiresAtMs < Date.now()
2425
}

freebuff/web/src/app/onboard/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import { getServerSession } from 'next-auth'
66

77
import {
88
checkFingerprintConflict,
9-
checkReplayAttack,
109
createCliSession,
1110
getSessionTokenFromCookies,
11+
hasCliSessionForAuthHash,
1212
} from './_db'
1313
import { isAuthCodeExpired, parseAuthCode, validateAuthCode } from './_helpers'
1414
import { authOptions } from '../api/auth/[...nextauth]/auth-options'
@@ -119,7 +119,7 @@ const Onboard = async ({ searchParams }: PageProps) => {
119119
)
120120
}
121121

122-
const isReplay = await checkReplayAttack(fingerprintHash, user.id)
122+
const isReplay = await hasCliSessionForAuthHash(fingerprintHash, user.id)
123123
if (isReplay) {
124124
return (
125125
<StatusCard
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE "session" ADD COLUMN "cli_auth_hash" text;

0 commit comments

Comments
 (0)