Skip to content

Commit 421b4a3

Browse files
committed
chore(sync): merge dev into mobile-main
2 parents 86af103 + aea74f8 commit 421b4a3

36 files changed

Lines changed: 804 additions & 127 deletions

File tree

apps/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "folocli",
33
"type": "module",
4-
"version": "0.0.1",
4+
"version": "0.0.4",
55
"description": "Folo CLI for terminal workflows and automation",
66
"author": "Folo Team",
77
"license": "AGPL-3.0-only",

apps/cli/src/auth-command.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, expect, it, vi } from "vitest"
2+
3+
vi.mock("./browser-login", () => ({
4+
loginWithBrowser: vi.fn(),
5+
resolveBrowserLoginToken: vi.fn(),
6+
}))
7+
8+
const { loginWithBrowser, resolveBrowserLoginToken } = await import("./browser-login")
9+
const { resolveLoginToken } = await import("./commands/auth")
10+
11+
describe("resolveLoginToken", () => {
12+
it("exchanges a provided one-time token into a session token", async () => {
13+
vi.mocked(resolveBrowserLoginToken).mockResolvedValueOnce("session-token")
14+
15+
await expect(
16+
resolveLoginToken({
17+
inputToken: "one-time-token",
18+
apiUrl: "https://api.folo.is",
19+
timeoutMs: 180_000,
20+
onStatus: vi.fn(),
21+
}),
22+
).resolves.toBe("session-token")
23+
24+
expect(resolveBrowserLoginToken).toHaveBeenCalledWith("https://api.folo.is", "one-time-token")
25+
})
26+
27+
it("falls back to browser login when no token is provided", async () => {
28+
vi.mocked(loginWithBrowser).mockResolvedValueOnce({
29+
token: "browser-session-token",
30+
callbackUrl: "http://127.0.0.1/callback",
31+
loginUrl: "https://app.folo.is/login",
32+
})
33+
34+
await expect(
35+
resolveLoginToken({
36+
apiUrl: "https://api.folo.is",
37+
timeoutMs: 180_000,
38+
onStatus: vi.fn(),
39+
}),
40+
).resolves.toBe("browser-session-token")
41+
})
42+
})

apps/cli/src/browser-login.test.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,15 @@ describe("browser login helpers", () => {
5656
const fetchMock = vi.fn().mockResolvedValueOnce(
5757
new Response(
5858
JSON.stringify({
59-
session: { token: "session-token" },
6059
user: { id: "user-1" },
6160
}),
6261
{
6362
status: 200,
64-
headers: { "content-type": "application/json" },
63+
headers: {
64+
"content-type": "application/json",
65+
"set-cookie":
66+
"__Secure-better-auth.session_token=session-token; Path=/; HttpOnly; Secure; SameSite=None",
67+
},
6568
},
6669
),
6770
)
@@ -72,6 +75,49 @@ describe("browser login helpers", () => {
7275
expect(token).toBe("session-token")
7376
expect(fetchMock).toHaveBeenCalledTimes(1)
7477
expect(fetchMock).toHaveBeenCalledWith(
78+
"https://api.folo.is/better-auth/one-time-token/apply",
79+
expect.objectContaining({
80+
method: "POST",
81+
body: JSON.stringify({ token: "one-time-token" }),
82+
}),
83+
)
84+
})
85+
86+
it("falls back to verify when apply endpoint is unavailable", async () => {
87+
const fetchMock = vi
88+
.fn()
89+
.mockResolvedValueOnce(
90+
new Response(null, {
91+
status: 404,
92+
}),
93+
)
94+
.mockResolvedValueOnce(
95+
new Response(
96+
JSON.stringify({
97+
session: { token: "session-token" },
98+
user: { id: "user-1" },
99+
}),
100+
{
101+
status: 200,
102+
headers: { "content-type": "application/json" },
103+
},
104+
),
105+
)
106+
vi.stubGlobal("fetch", fetchMock)
107+
108+
const token = await resolveBrowserLoginToken(DEFAULT_VALUES.PROD.API_URL, "one-time-token")
109+
110+
expect(token).toBe("session-token")
111+
expect(fetchMock).toHaveBeenNthCalledWith(
112+
1,
113+
"https://api.folo.is/better-auth/one-time-token/apply",
114+
expect.objectContaining({
115+
method: "POST",
116+
body: JSON.stringify({ token: "one-time-token" }),
117+
}),
118+
)
119+
expect(fetchMock).toHaveBeenNthCalledWith(
120+
2,
75121
"https://api.folo.is/better-auth/one-time-token/verify",
76122
expect.objectContaining({
77123
method: "POST",

apps/cli/src/browser-login.ts

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { CLIError } from "./output"
88
const LOCAL_CALLBACK_HOST = "127.0.0.1"
99
const LOCAL_CALLBACK_PATH = "/callback"
1010
const DEFAULT_TIMEOUT_MS = 3 * 60 * 1000
11+
const ONE_TIME_TOKEN_APPLY_PATH = "/better-auth/one-time-token/apply"
1112
const ONE_TIME_TOKEN_VERIFY_PATH = "/better-auth/one-time-token/verify"
1213
const SESSION_CHECK_PATH = "/better-auth/get-session"
1314

@@ -114,6 +115,38 @@ const isRecord = (value: unknown): value is Record<string, unknown> => {
114115
return typeof value === "object" && value !== null && !Array.isArray(value)
115116
}
116117

118+
const readSetCookieValues = (response: Response): string[] => {
119+
if (typeof response.headers.getSetCookie === "function") {
120+
return response.headers.getSetCookie()
121+
}
122+
123+
const setCookie = response.headers.get("set-cookie")
124+
return setCookie ? [setCookie] : []
125+
}
126+
127+
const extractSessionTokenFromSetCookie = (setCookieValues: string[]): string | undefined => {
128+
for (const setCookie of setCookieValues) {
129+
const match = setCookie.match(/(?:__Secure-)?better-auth\.session_token=([^;]+)/)
130+
if (match?.[1]) {
131+
return match[1]
132+
}
133+
}
134+
135+
return undefined
136+
}
137+
138+
const extractSessionTokenFromBody = (data: unknown): string | undefined => {
139+
if (!isRecord(data)) {
140+
return undefined
141+
}
142+
143+
if (isRecord(data.session) && typeof data.session.token === "string") {
144+
return data.session.token
145+
}
146+
147+
return undefined
148+
}
149+
117150
const extractErrorMessage = async (response: Response): Promise<string | undefined> => {
118151
const contentType = response.headers.get("content-type") ?? ""
119152

@@ -147,42 +180,80 @@ const hasValidSessionToken = async (apiUrl: string, token: string): Promise<bool
147180
}
148181

149182
export const resolveBrowserLoginToken = async (apiUrl: string, token: string): Promise<string> => {
183+
const applyUrl = resolveAuthEndpointUrl(apiUrl, ONE_TIME_TOKEN_APPLY_PATH)
150184
const verifyUrl = resolveAuthEndpointUrl(apiUrl, ONE_TIME_TOKEN_VERIFY_PATH)
151185

152-
let response: Response
186+
const requestBody = JSON.stringify({ token })
187+
188+
let response: Response | undefined
189+
let errorMessage: string | undefined
153190
try {
154-
response = await fetch(verifyUrl, {
191+
response = await fetch(applyUrl, {
155192
method: "POST",
156193
headers: {
157194
"content-type": "application/json",
158195
},
159-
body: JSON.stringify({ token }),
196+
body: requestBody,
160197
})
161198
} catch (error) {
162199
throw new CLIError(
163200
"NETWORK_ERROR",
164-
`Failed to verify browser login token: ${(error as Error).message}`,
201+
`Failed to apply browser login token: ${(error as Error).message}`,
165202
)
166203
}
167204

168205
if (response.ok) {
169206
const data = (await response.json().catch(() => null)) as unknown
170207
const sessionToken =
171-
isRecord(data) && isRecord(data.session) && typeof data.session.token === "string"
172-
? data.session.token
173-
: undefined
208+
extractSessionTokenFromSetCookie(readSetCookieValues(response)) ??
209+
extractSessionTokenFromBody(data)
174210

175211
if (!sessionToken) {
176212
throw new CLIError(
177213
"UNAUTHORIZED",
178-
"Browser login verification succeeded without returning a session token.",
214+
"Browser login token apply succeeded without returning a session token.",
179215
)
180216
}
181217

182218
return sessionToken
183219
}
184220

185-
const errorMessage = await extractErrorMessage(response)
221+
errorMessage = await extractErrorMessage(response)
222+
223+
if (response.status === 404) {
224+
try {
225+
response = await fetch(verifyUrl, {
226+
method: "POST",
227+
headers: {
228+
"content-type": "application/json",
229+
},
230+
body: requestBody,
231+
})
232+
} catch (error) {
233+
throw new CLIError(
234+
"NETWORK_ERROR",
235+
`Failed to verify browser login token: ${(error as Error).message}`,
236+
)
237+
}
238+
239+
if (response.ok) {
240+
const data = (await response.json().catch(() => null)) as unknown
241+
const sessionToken =
242+
extractSessionTokenFromSetCookie(readSetCookieValues(response)) ??
243+
extractSessionTokenFromBody(data)
244+
245+
if (!sessionToken) {
246+
throw new CLIError(
247+
"UNAUTHORIZED",
248+
"Browser login verification succeeded without returning a session token.",
249+
)
250+
}
251+
252+
return sessionToken
253+
}
254+
255+
errorMessage = await extractErrorMessage(response)
256+
}
186257

187258
try {
188259
if (await hasValidSessionToken(apiUrl, token)) {

apps/cli/src/client.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const readString = (value: unknown): string | undefined => {
1212
return typeof value === "string" && value.length > 0 ? value : undefined
1313
}
1414

15-
const normalizeToken = (token: string | undefined) => {
15+
export const normalizeToken = (token: string | undefined) => {
1616
if (!token || !token.includes("%")) {
1717
return token
1818
}
@@ -42,6 +42,61 @@ export interface CommandContext {
4242
token?: string
4343
}
4444

45+
export interface CLIAuthSession {
46+
user?: Record<string, unknown>
47+
session?: Record<string, unknown>
48+
role?: unknown
49+
roleEndAt?: unknown
50+
feedSubscriptionLimit?: unknown
51+
rsshubSubscriptionLimit?: unknown
52+
}
53+
54+
const readSessionErrorMessage = async (response: Response): Promise<string | undefined> => {
55+
const contentType = response.headers.get("content-type") ?? ""
56+
57+
if (contentType.includes("application/json")) {
58+
const data = (await response.json().catch(() => null)) as Record<string, unknown> | null
59+
return typeof data?.message === "string" ? data.message : undefined
60+
}
61+
62+
const text = await response.text().catch(() => "")
63+
return text || undefined
64+
}
65+
66+
export const fetchAuthSession = async ({
67+
apiUrl,
68+
token,
69+
verbose = false,
70+
}: {
71+
apiUrl: string
72+
token: string
73+
verbose?: boolean
74+
}): Promise<CLIAuthSession> => {
75+
const requestUrl = `${apiUrl}/better-auth/get-session`
76+
if (verbose) {
77+
console.error(`[request] GET ${requestUrl}`)
78+
}
79+
80+
const response = await fetch(requestUrl, {
81+
method: "GET",
82+
headers: {
83+
Authorization: `Bearer ${token}`,
84+
Cookie: `__Secure-better-auth.session_token=${token}; better-auth.session_token=${token}`,
85+
},
86+
})
87+
88+
if (verbose) {
89+
console.error(`[response] GET ${requestUrl} -> ${response.status}`)
90+
}
91+
92+
if (!response.ok) {
93+
const message = await readSessionErrorMessage(response)
94+
throw new CLIError("UNAUTHORIZED", message || "Token is invalid or expired.")
95+
}
96+
97+
return (await response.json()) as CLIAuthSession
98+
}
99+
45100
export const getGlobalOptions = (command: Command): GlobalOptions => {
46101
const options = command.optsWithGlobals() as Record<string, unknown>
47102

0 commit comments

Comments
 (0)