Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "stellar-streaming-api",
"version": "1.0.0",
"version": "1.1.0",
"description": "Stellar Streaming API built with NestJS",
"main": "dist/main.js",
"scripts": {
Expand Down Expand Up @@ -57,6 +57,7 @@
"@nestjs/testing": "^10.4.22",
"@types/bcrypt": "^6.0.0",
"@types/compression": "^1.8.1",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/jest": "^29.5.14",
"@types/node": "^20.10.0",
Expand Down
96 changes: 96 additions & 0 deletions api/src/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { UnauthorizedException } from "@nestjs/common"
import { AuthController } from "./auth.controller"
import { AuthResponse, AuthService } from "./auth.service"

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

interface MockAuthService {
refresh: jest.Mock<Promise<AuthResponse>>
}

function mockAuthService(): MockAuthService {
return {
refresh: jest.fn(),
}
}

function makeController(service: MockAuthService): AuthController {
return new AuthController(service as unknown as AuthService)
}

function authResponse(): AuthResponse {
return {
user: {
id: 1,
username: "testuser",
email: "test@example.com",
createdAt: new Date("2026-01-01T00:00:00Z"),
},
accessToken: "access.token",
refreshToken: "refresh.token",
}
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("AuthController", () => {
let service: MockAuthService
let controller: AuthController

beforeEach(() => {
service = mockAuthService()
controller = makeController(service)
jest.clearAllMocks()
})

describe("refresh", () => {
it("forwards the body refresh token to the service", async () => {
service.refresh.mockResolvedValue(authResponse())

const result = await controller.refresh("body.token", undefined)

expect(service.refresh).toHaveBeenCalledWith("body.token")
expect(result).toEqual(authResponse())
})

it("falls back to the refresh_token cookie when no body token is present", async () => {
service.refresh.mockResolvedValue(authResponse())

const result = await controller.refresh(undefined, {
cookies: { refresh_token: "cookie.token" },
})

expect(service.refresh).toHaveBeenCalledWith("cookie.token")
expect(result).toEqual(authResponse())
})

it("prefers the body token over the cookie when both are present", async () => {
service.refresh.mockResolvedValue(authResponse())

await controller.refresh("body.token", {
cookies: { refresh_token: "cookie.token" },
})

expect(service.refresh).toHaveBeenCalledWith("body.token")
expect(service.refresh).not.toHaveBeenCalledWith("cookie.token")
})

it("throws UnauthorizedException when neither body token nor cookie is provided", () => {
expect(() => controller.refresh(undefined, undefined)).toThrow(
UnauthorizedException,
)
expect(service.refresh).not.toHaveBeenCalled()
})

it("throws UnauthorizedException when the cookie is empty", () => {
expect(() => controller.refresh(undefined, { cookies: {} })).toThrow(
UnauthorizedException,
)
expect(service.refresh).not.toHaveBeenCalled()
})
})
})
23 changes: 23 additions & 0 deletions api/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,27 @@ export class AuthController {
message: "Password has been reset successfully.",
}
}

@Post("refresh")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Refresh an expired access token",
description:
"Accepts a refresh token via the request body (`refreshToken`) or via " +
"the `refresh_token` httpOnly cookie. Returns a fresh access token, " +
"refresh token, and the user profile.",
})
@ApiOkResponse({
description: "Token refresh successful. New token pair returned.",
})
refresh(
@Body("refreshToken") bodyToken?: string,
@Req() req?: { cookies?: Record<string, string> },
): Promise<AuthResponse> {
const token = bodyToken ?? req?.cookies?.refresh_token
if (!token) {
throw new UnauthorizedException("refresh token is required")
}
return this.authService.refresh(token)
}
}
69 changes: 69 additions & 0 deletions api/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,4 +586,73 @@ describe("AuthService", () => {
await expect(service.refresh(req)).rejects.toThrow(UnauthorizedException)
})
})

// -- refresh -----------------------------------------------------------

describe("refresh", () => {
it("returns new token pair for a valid refresh token", async () => {
const user = dummyUser()
jwt.verify.mockReturnValue({ sub: user.id })
users.findById.mockResolvedValue(user)
jwt.sign.mockReturnValueOnce("new.access.token").mockReturnValueOnce("new.refresh.token")

const result = await service.refresh("valid.refresh.token")

expect(jwt.verify).toHaveBeenCalledWith("valid.refresh.token")
expect(users.findById).toHaveBeenCalledWith(user.id)
expect(result.accessToken).toBe("new.access.token")
expect(result.refreshToken).toBe("new.refresh.token")
expect(result.user).toEqual({
id: user.id,
username: user.username,
email: user.email,
createdAt: user.created_at,
})
})

it("throws UnauthorizedException when the refresh token is invalid or expired", async () => {
jwt.verify.mockImplementation(() => {
throw new Error("jwt expired")
})

await expect(service.refresh("expired.token")).rejects.toThrow(
UnauthorizedException,
)
expect(users.findById).not.toHaveBeenCalled()
})

it("throws UnauthorizedException when the user no longer exists", async () => {
jwt.verify.mockReturnValue({ sub: 999 })
users.findById.mockResolvedValue(null)

await expect(service.refresh("valid.for.deleted.user")).rejects.toThrow(
UnauthorizedException,
)
expect(jwt.sign).not.toHaveBeenCalled()
})

it("signs the access token with the standard short-lived payload", async () => {
const user = dummyUser()
jwt.verify.mockReturnValue({ sub: user.id })
users.findById.mockResolvedValue(user)
jwt.sign
.mockReturnValueOnce("access")
.mockReturnValueOnce("refresh")

await service.refresh("token")

// First call: access token (short-lived, full claims)
expect(jwt.sign).toHaveBeenNthCalledWith(1, {
sub: user.id,
email: user.email,
username: user.username,
})
// Second call: refresh token (long-lived, sub only)
expect(jwt.sign).toHaveBeenNthCalledWith(
2,
{ sub: user.id },
{ expiresIn: "7d" },
)
})
})
})
8 changes: 8 additions & 0 deletions api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,14 @@ export class AuthService {
jti: randomUUID(),
})
}

/** Create a long-lived JWT refresh token for the given user. */
private signRefreshToken(user: User): string {
return this.jwtService.sign(
{ sub: user.id },
{ expiresIn: "7d" },
)
}
}

/** Strip the password hash from a user row before returning to clients. */
Expand Down
8 changes: 8 additions & 0 deletions api/src/auth/users.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ export class UsersRepository {
return rows[0] ?? null
}

async findById(id: number): Promise<User | null> {
const { rows } = await this.pool.query(
"SELECT id, username, email, password_hash, created_at FROM users WHERE id = $1",
[id],
)
return rows[0] ?? null
}

async findByUsername(username: string): Promise<User | null> {
const { rows } = await this.pool.query(
"SELECT id, username, email, password_hash, created_at, password_changed_at, is_admin FROM users WHERE username = $1",
Expand Down
4 changes: 4 additions & 0 deletions api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ValidationPipe } from "@nestjs/common"
import { HttpAdapterHost, NestFactory } from "@nestjs/core"
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"
import compression from "compression"
import cookieParser from "cookie-parser"
import helmet from "helmet"
import * as cookieParser from "cookie-parser"
import { AppModule } from "./app.module"
Expand All @@ -27,6 +28,9 @@ async function bootstrap() {

const app = await NestFactory.create(AppModule)

// Parse cookies for refresh-token handling.
app.use(cookieParser())

// Issue #89: Apply Helmet middleware globally for secure HTTP headers
app.use(
helmet({
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion xstreamroll-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,11 @@ const client = new StreamingClient({
})

// 1. Log in
const { accessToken, refreshToken } = await client.login(
const { user, accessToken, refreshToken } = await client.login(
"alice@example.com",
"super-secret-password",
)
// user: { id, email, displayName, role, createdAt, updatedAt }

// 2. Publish an event to an existing stream.
// `clientId` is auto-filled with a stable per-instance id; pass
Expand Down Expand Up @@ -150,6 +151,11 @@ const registerTokens = await client.register({
password: "super-secret-password",
displayName: "Alice",
})
// tokens: { user, accessToken, refreshToken }

// Refresh an expired access token
const freshTokens = await client.refreshToken()
// freshTokens: { user, accessToken, refreshToken }
```

`StreamingClient` keeps the active tokens on the instance and:
Expand Down
Loading
Loading