Skip to content

Commit e25d74a

Browse files
fix(sdk,api): align SDK auth types with API and fix refresh token flow
- Replace AuthTokens with AuthResponse in SDK types — add `user` field, remove the non-existent `expiresIn` field that the API never returns. AuthTokens is kept as a deprecated type alias for backward compatibility. - Fix refreshToken() to POST the refresh token in the request body instead of relying on httpOnly cookies, which were inaccessible to the SDK in Node and cross-origin browser contexts. - Add POST /auth/refresh endpoint that accepts the refresh token either from the request body (`refreshToken`) or from the `refresh_token` httpOnly cookie, keeping the existing Next.js proxy flow working. - Include a refresh token in login() and register() API responses so the SDK has a token to send on refresh. - Add findById() to UsersRepository for refresh-token user lookup. - Add cookie-parser middleware to parse cookies for the cookie-based refresh path. - Add comprehensive unit tests: AuthController.refresh, AuthService.refresh, SDK client auth flows (login, register, refreshToken, 401 auto-refresh). - Update SDK README with corrected types and refresh usage examples. - Bump both @stellar/streaming-sdk and stellar-streaming-api to v1.1.0. Closes #527 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
1 parent 18810d5 commit e25d74a

14 files changed

Lines changed: 614 additions & 15 deletions

api/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "stellar-streaming-api",
3-
"version": "1.0.0",
3+
"version": "1.1.0",
44
"description": "Stellar Streaming API built with NestJS",
55
"main": "dist/main.js",
66
"scripts": {
@@ -27,6 +27,7 @@
2727
"class-transformer": "^0.5.1",
2828
"class-validator": "^0.15.1",
2929
"compression": "^1.8.1",
30+
"cookie-parser": "^1.4.7",
3031
"helmet": "8.1.0",
3132
"pg": "^8.20.0",
3233
"reflect-metadata": "^0.1.13",
@@ -40,6 +41,7 @@
4041
"@nestjs/cli": "^10.3.0",
4142
"@types/bcrypt": "^6.0.0",
4243
"@types/compression": "^1.8.1",
44+
"@types/cookie-parser": "^1.4.10",
4345
"@types/express": "^5.0.6",
4446
"@types/jest": "^29.5.14",
4547
"@types/node": "^20.10.0",
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { UnauthorizedException } from "@nestjs/common"
2+
import { AuthController } from "./auth.controller"
3+
import { AuthResponse, AuthService } from "./auth.service"
4+
5+
// ---------------------------------------------------------------------------
6+
// Helpers
7+
// ---------------------------------------------------------------------------
8+
9+
interface MockAuthService {
10+
refresh: jest.Mock<Promise<AuthResponse>>
11+
}
12+
13+
function mockAuthService(): MockAuthService {
14+
return {
15+
refresh: jest.fn(),
16+
}
17+
}
18+
19+
function makeController(service: MockAuthService): AuthController {
20+
return new AuthController(service as unknown as AuthService)
21+
}
22+
23+
function authResponse(): AuthResponse {
24+
return {
25+
user: {
26+
id: 1,
27+
username: "testuser",
28+
email: "test@example.com",
29+
createdAt: new Date("2026-01-01T00:00:00Z"),
30+
},
31+
accessToken: "access.token",
32+
refreshToken: "refresh.token",
33+
}
34+
}
35+
36+
// ---------------------------------------------------------------------------
37+
// Tests
38+
// ---------------------------------------------------------------------------
39+
40+
describe("AuthController", () => {
41+
let service: MockAuthService
42+
let controller: AuthController
43+
44+
beforeEach(() => {
45+
service = mockAuthService()
46+
controller = makeController(service)
47+
jest.clearAllMocks()
48+
})
49+
50+
describe("refresh", () => {
51+
it("forwards the body refresh token to the service", async () => {
52+
service.refresh.mockResolvedValue(authResponse())
53+
54+
const result = await controller.refresh("body.token", undefined)
55+
56+
expect(service.refresh).toHaveBeenCalledWith("body.token")
57+
expect(result).toEqual(authResponse())
58+
})
59+
60+
it("falls back to the refresh_token cookie when no body token is present", async () => {
61+
service.refresh.mockResolvedValue(authResponse())
62+
63+
const result = await controller.refresh(undefined, {
64+
cookies: { refresh_token: "cookie.token" },
65+
})
66+
67+
expect(service.refresh).toHaveBeenCalledWith("cookie.token")
68+
expect(result).toEqual(authResponse())
69+
})
70+
71+
it("prefers the body token over the cookie when both are present", async () => {
72+
service.refresh.mockResolvedValue(authResponse())
73+
74+
await controller.refresh("body.token", {
75+
cookies: { refresh_token: "cookie.token" },
76+
})
77+
78+
expect(service.refresh).toHaveBeenCalledWith("body.token")
79+
expect(service.refresh).not.toHaveBeenCalledWith("cookie.token")
80+
})
81+
82+
it("throws UnauthorizedException when neither body token nor cookie is provided", () => {
83+
expect(() => controller.refresh(undefined, undefined)).toThrow(
84+
UnauthorizedException,
85+
)
86+
expect(service.refresh).not.toHaveBeenCalled()
87+
})
88+
89+
it("throws UnauthorizedException when the cookie is empty", () => {
90+
expect(() => controller.refresh(undefined, { cookies: {} })).toThrow(
91+
UnauthorizedException,
92+
)
93+
expect(service.refresh).not.toHaveBeenCalled()
94+
})
95+
})
96+
})

api/src/auth/auth.controller.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
HttpCode,
55
HttpStatus,
66
Post,
7+
Req,
8+
UnauthorizedException,
79
} from "@nestjs/common"
810
import { ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from "@nestjs/swagger"
911
import { AuthResponse, AuthService } from "./auth.service"
@@ -43,4 +45,27 @@ export class AuthController {
4345
login(@Body() dto: LoginDto): Promise<AuthResponse> {
4446
return this.authService.login(dto)
4547
}
48+
49+
@Post("refresh")
50+
@HttpCode(HttpStatus.OK)
51+
@ApiOperation({
52+
summary: "Refresh an expired access token",
53+
description:
54+
"Accepts a refresh token via the request body (`refreshToken`) or via " +
55+
"the `refresh_token` httpOnly cookie. Returns a fresh access token, " +
56+
"refresh token, and the user profile.",
57+
})
58+
@ApiOkResponse({
59+
description: "Token refresh successful. New token pair returned.",
60+
})
61+
refresh(
62+
@Body("refreshToken") bodyToken?: string,
63+
@Req() req?: { cookies?: Record<string, string> },
64+
): Promise<AuthResponse> {
65+
const token = bodyToken ?? req?.cookies?.refresh_token
66+
if (!token) {
67+
throw new UnauthorizedException("refresh token is required")
68+
}
69+
return this.authService.refresh(token)
70+
}
4671
}

api/src/auth/auth.service.spec.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,28 @@ jest.mock("bcrypt", () => ({
1515

1616
interface MockJwtService {
1717
sign: jest.Mock<string>
18+
verify: jest.Mock<{ sub: number }>
1819
}
1920

2021
interface MockUsersRepository {
2122
findByEmail: jest.Mock<Promise<User | null>>
2223
findByUsername: jest.Mock<Promise<User | null>>
24+
findById: jest.Mock<Promise<User | null>>
2325
create: jest.Mock<Promise<User>>
2426
}
2527

2628
function mockJwtService(): MockJwtService {
2729
return {
2830
sign: jest.fn(),
31+
verify: jest.fn(),
2932
}
3033
}
3134

3235
function mockUsersRepository(): MockUsersRepository {
3336
return {
3437
findByEmail: jest.fn(),
3538
findByUsername: jest.fn(),
39+
findById: jest.fn(),
3640
create: jest.fn(),
3741
}
3842
}
@@ -106,6 +110,7 @@ describe("AuthService", () => {
106110
username: dto.username,
107111
})
108112
expect(result.accessToken).toBe("jwt.token.here")
113+
expect(result.refreshToken).toBe("jwt.token.here")
109114
expect(result.user).toEqual({
110115
id: 1,
111116
username: dto.username,
@@ -175,6 +180,7 @@ describe("AuthService", () => {
175180
username: user.username,
176181
})
177182
expect(result.accessToken).toBe("jwt.token.here")
183+
expect(result.refreshToken).toBe("jwt.token.here")
178184
expect(result.user).toEqual({
179185
id: user.id,
180186
username: user.username,
@@ -237,4 +243,73 @@ describe("AuthService", () => {
237243
})
238244
})
239245
})
246+
247+
// -- refresh -----------------------------------------------------------
248+
249+
describe("refresh", () => {
250+
it("returns new token pair for a valid refresh token", async () => {
251+
const user = dummyUser()
252+
jwt.verify.mockReturnValue({ sub: user.id })
253+
users.findById.mockResolvedValue(user)
254+
jwt.sign.mockReturnValueOnce("new.access.token").mockReturnValueOnce("new.refresh.token")
255+
256+
const result = await service.refresh("valid.refresh.token")
257+
258+
expect(jwt.verify).toHaveBeenCalledWith("valid.refresh.token")
259+
expect(users.findById).toHaveBeenCalledWith(user.id)
260+
expect(result.accessToken).toBe("new.access.token")
261+
expect(result.refreshToken).toBe("new.refresh.token")
262+
expect(result.user).toEqual({
263+
id: user.id,
264+
username: user.username,
265+
email: user.email,
266+
createdAt: user.created_at,
267+
})
268+
})
269+
270+
it("throws UnauthorizedException when the refresh token is invalid or expired", async () => {
271+
jwt.verify.mockImplementation(() => {
272+
throw new Error("jwt expired")
273+
})
274+
275+
await expect(service.refresh("expired.token")).rejects.toThrow(
276+
UnauthorizedException,
277+
)
278+
expect(users.findById).not.toHaveBeenCalled()
279+
})
280+
281+
it("throws UnauthorizedException when the user no longer exists", async () => {
282+
jwt.verify.mockReturnValue({ sub: 999 })
283+
users.findById.mockResolvedValue(null)
284+
285+
await expect(service.refresh("valid.for.deleted.user")).rejects.toThrow(
286+
UnauthorizedException,
287+
)
288+
expect(jwt.sign).not.toHaveBeenCalled()
289+
})
290+
291+
it("signs the access token with the standard short-lived payload", async () => {
292+
const user = dummyUser()
293+
jwt.verify.mockReturnValue({ sub: user.id })
294+
users.findById.mockResolvedValue(user)
295+
jwt.sign
296+
.mockReturnValueOnce("access")
297+
.mockReturnValueOnce("refresh")
298+
299+
await service.refresh("token")
300+
301+
// First call: access token (short-lived, full claims)
302+
expect(jwt.sign).toHaveBeenNthCalledWith(1, {
303+
sub: user.id,
304+
email: user.email,
305+
username: user.username,
306+
})
307+
// Second call: refresh token (long-lived, sub only)
308+
expect(jwt.sign).toHaveBeenNthCalledWith(
309+
2,
310+
{ sub: user.id },
311+
{ expiresIn: "7d" },
312+
)
313+
})
314+
})
240315
})

api/src/auth/auth.service.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface SafeUser {
2323
export interface AuthResponse {
2424
user: SafeUser
2525
accessToken: string
26+
refreshToken: string
2627
}
2728

2829
@Injectable()
@@ -62,6 +63,7 @@ export class AuthService {
6263
return {
6364
user: toSafeUser(user),
6465
accessToken: this.signToken(user),
66+
refreshToken: this.signRefreshToken(user),
6567
}
6668
}
6769

@@ -85,6 +87,34 @@ export class AuthService {
8587
return {
8688
user: toSafeUser(user),
8789
accessToken: this.signToken(user),
90+
refreshToken: this.signRefreshToken(user),
91+
}
92+
}
93+
94+
/**
95+
* Refresh an access token using a valid refresh token.
96+
*
97+
* Accepts the refresh token either from the request body (SDK path) or
98+
* from the httpOnly cookie (browser proxy path). Validates the token,
99+
* looks up the user, and returns a fresh token pair.
100+
*/
101+
async refresh(refreshToken: string): Promise<AuthResponse> {
102+
let payload: { sub: number }
103+
try {
104+
payload = this.jwtService.verify<{ sub: number }>(refreshToken)
105+
} catch {
106+
throw new UnauthorizedException("invalid or expired refresh token")
107+
}
108+
109+
const user = await this.usersRepository.findById(payload.sub)
110+
if (!user) {
111+
throw new UnauthorizedException("user not found")
112+
}
113+
114+
return {
115+
user: toSafeUser(user),
116+
accessToken: this.signToken(user),
117+
refreshToken: this.signRefreshToken(user),
88118
}
89119
}
90120

@@ -96,6 +126,14 @@ export class AuthService {
96126
username: user.username,
97127
})
98128
}
129+
130+
/** Create a long-lived JWT refresh token for the given user. */
131+
private signRefreshToken(user: User): string {
132+
return this.jwtService.sign(
133+
{ sub: user.id },
134+
{ expiresIn: "7d" },
135+
)
136+
}
99137
}
100138

101139
/** Strip the password hash from a user row before returning to clients. */

api/src/auth/users.repository.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ export class UsersRepository {
2727
return rows[0] ?? null
2828
}
2929

30+
async findById(id: number): Promise<User | null> {
31+
const { rows } = await this.pool.query(
32+
"SELECT id, username, email, password_hash, created_at FROM users WHERE id = $1",
33+
[id],
34+
)
35+
return rows[0] ?? null
36+
}
37+
3038
async findByUsername(username: string): Promise<User | null> {
3139
const { rows } = await this.pool.query(
3240
"SELECT id, username, email, password_hash, created_at FROM users WHERE username = $1",

api/src/main.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ValidationPipe } from "@nestjs/common"
22
import { NestFactory } from "@nestjs/core"
33
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"
44
import compression from "compression"
5+
import cookieParser from "cookie-parser"
56
import helmet from "helmet"
67
import { AppModule } from "./app.module"
78
import { SanitizeStringsPipe } from "./common/sanitization/sanitize-strings.pipe"
@@ -15,6 +16,9 @@ const COMPRESSION_THRESHOLD_BYTES = 1024
1516
async function bootstrap() {
1617
const app = await NestFactory.create(AppModule)
1718

19+
// Parse cookies for refresh-token handling.
20+
app.use(cookieParser())
21+
1822
// Issue #89: Apply Helmet middleware globally for secure HTTP headers
1923
app.use(
2024
helmet({

0 commit comments

Comments
 (0)