Skip to content

Commit f9f4f75

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

14 files changed

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

116
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),
17-
})
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-
}
7+
return getLoginStatus({ req, logger, secret: env.NEXTAUTH_SECRET })
1148
}

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

Lines changed: 10 additions & 11 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'
@@ -17,17 +17,14 @@ export async function checkReplayAttack(
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)
@@ -48,6 +45,7 @@ export async function checkFingerprintConflict(
4845
.where(
4946
and(
5047
eq(schema.session.fingerprint_id, fingerprintId),
48+
ne(schema.session.userId, userId),
5149
gt(schema.session.expires, new Date()),
5250
),
5351
)
@@ -80,7 +78,7 @@ export async function createCliSession(
8078
return db.transaction(async (tx: DbTransaction) => {
8179
await tx
8280
.insert(schema.fingerprint)
83-
.values({ sig_hash: fingerprintHash, id: fingerprintId })
81+
.values({ id: fingerprintId })
8482
.onConflictDoNothing()
8583

8684
const session = await tx
@@ -90,6 +88,7 @@ export async function createCliSession(
9088
userId,
9189
expires: MAX_DATE,
9290
fingerprint_id: fingerprintId,
91+
cli_auth_hash: fingerprintHash,
9392
type: 'cli',
9493
})
9594
.returning({ userId: schema.session.userId })
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)