Skip to content

Commit aaf75d7

Browse files
authored
Merge pull request #9 from peek-travel/feature/expose-jwt-helpers
feat: expose jwt helpers for app starter kit
2 parents 6131f8a + 4739e03 commit aaf75d7

6 files changed

Lines changed: 186 additions & 3 deletions

File tree

docs/internal/ARCHITECTURE.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ call typed methods like `peek.getProductService().getAllProducts()`.
4444
- Constructs a single shared `TokenManager` and `GraphQLClient`.
4545
- Exposes one `get<Resource>Service()` accessor per resource. Each is **lazily
4646
created and memoized** — repeated calls return the same instance.
47+
- Exposes `verifyPeekAuthToken(token)` to verify HMAC-signed JWTs issued by
48+
the Peek app registry (`iss: "app_registry_v2"`), returning
49+
a fully typed `PeekAuthTokenClaims` (including the nested `PeekAuthTokenUser`
50+
object). Throws `JsonWebTokenError` / `TokenExpiredError` / `NotBeforeError`
51+
from `jsonwebtoken` on failure.
4752
- Composes dependencies between services where needed:
4853
- `TimeslotService` receives the resource-pool and account-user services (for
4954
guide resolution).
@@ -196,8 +201,8 @@ Recurring patterns inside services:
196201

197202
The barrel re-exports only the public contract: `PeekAccessService` + its config,
198203
each resource service class (and the options/result types callers need), all
199-
data-model **types**, the `Logger` interface + `noopLogger`, and the three typed
200-
error classes. Query strings and raw response interfaces are deliberately kept
204+
data-model **types** (including `PeekAuthTokenClaims` and `PeekAuthTokenUser`),
205+
the `Logger` interface + `noopLogger`, and the three typed error classes. Query strings and raw response interfaces are deliberately kept
201206
internal — including the booking-webhook registration query
202207
(`BOOKING_WEBHOOK_GQL_QUERY` stays internal, documented via `docs/webhooks.md`).
203208
The webhook-related public exports are the two parsers `parseBookingWebhook` and

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@peektravel/app-utilities",
3-
"version": "0.2.2",
3+
"version": "0.2.3",
44
"description": "GraphQL JS mapping utilities extracted from the Peek Pro Autopilot connector.",
55
"license": "MIT",
66
"repository": {

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ export type {
115115
} from "./models/booking-addon.js";
116116
export type { Guide, Review } from "./models/review.js";
117117
export type { Waiver } from "./models/waiver.js";
118+
export type { PeekAuthTokenClaims, PeekAuthTokenUser } from "./models/auth-token.js";
118119

119120
export { noopLogger } from "./logger.js";
120121
export type { Logger } from "./logger.js";

src/models/auth-token.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/** User context embedded in a Peek auth token. */
2+
export interface PeekAuthTokenUser {
3+
/** User's email address. */
4+
email: string;
5+
/** User's Peek account ID */
6+
id: string;
7+
/** Whether the user has admin privileges. */
8+
isAdmin: boolean;
9+
/** User's locale (e.g. `"en"`). */
10+
locale: string;
11+
/** User's display name. */
12+
name: string;
13+
}
14+
15+
/** Claims returned by {@link PeekAccessService.verifyPeekAuthToken}. */
16+
export interface PeekAuthTokenClaims {
17+
/** Install ID — the JWT subject (`sub`). Peek-assigned UUID. */
18+
installId: string;
19+
/** App display version at time of issuance. */
20+
displayVersion: string;
21+
/** Authenticated user context. */
22+
user: PeekAuthTokenUser;
23+
}

src/peek-access-service.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* service objects — e.g. {@link PeekAccessService.getProductService} — that
88
* carry the resource-specific business logic.
99
*/
10+
import * as jwt from "jsonwebtoken";
1011
import { AccountUserService } from "./internal/account-users/account-user-service.js";
1112
import { AvailabilityService } from "./internal/availability/availability-service.js";
1213
import { BookingService } from "./internal/bookings/booking-service.js";
@@ -25,6 +26,7 @@ import { PromoCodeService } from "./internal/promo-codes/promo-code-service.js";
2526
import { V2_EXTENDABLE_SLUG } from "./internal/gateway-endpoints.js";
2627
import { TokenManager } from "./internal/token-manager.js";
2728
import { noopLogger, type Logger } from "./logger.js";
29+
import type { PeekAuthTokenClaims } from "./models/auth-token.js";
2830

2931
/** Default backoffice GraphQL gateway base URL (v1). */
3032
const DEFAULT_BASE_URL = "https://apps.peekapis.com/backoffice-gql";
@@ -37,6 +39,14 @@ const DEFAULT_TOKEN_TTL_SECONDS = 3600;
3739
const DEFAULT_TOKEN_REFRESH_LEEWAY_SECONDS = 60;
3840
/** Default HTTP 429 retry backoff. */
3941
const DEFAULT_RETRY_DELAYS_MS = [1000, 2000, 4000];
42+
/** JWT issuer set by the Peek app registry on all tokens it issues. */
43+
const PEEK_TOKEN_ISSUER = "app_registry_v2";
44+
45+
interface RawPeekTokenPayload {
46+
sub: string;
47+
display_version: string;
48+
user: { email: string; id: string; is_admin: boolean; locale: string; name: string };
49+
}
4050

4151
/** Configuration for a {@link PeekAccessService} instance. */
4252
export interface PeekAccessServiceConfig {
@@ -104,6 +114,7 @@ export interface PeekAccessServiceConfig {
104114
export class PeekAccessService {
105115
private readonly client: GraphQLClient;
106116
private readonly productServiceOptions: ProductServiceOptions;
117+
private readonly jwtSecret: string;
107118
private productService?: ProductService;
108119
private accountUserService?: AccountUserService;
109120
private resourcePoolService?: ResourcePoolService;
@@ -124,6 +135,8 @@ export class PeekAccessService {
124135
requireNonEmpty(config.appId, "appId");
125136
if (!isV2) requireNonEmpty(config.gatewayKey ?? "", "gatewayKey");
126137

138+
this.jwtSecret = config.jwtSecret;
139+
127140
const logger = config.logger ?? noopLogger;
128141
const tokens = new TokenManager({
129142
secret: config.jwtSecret,
@@ -152,6 +165,43 @@ export class PeekAccessService {
152165
};
153166
}
154167

168+
/**
169+
* Verifies a Peek auth token issued by the app registry and returns the
170+
* decoded claims.
171+
*
172+
* Validates the HMAC signature (using this service's `jwtSecret`), the token
173+
* expiry, the `"app_registry_v2"` issuer, and the `"Joken"` audience. Throws
174+
* from the `jsonwebtoken` library on any failure — callers should catch to
175+
* distinguish error kinds:
176+
*
177+
* - `JsonWebTokenError` — signature invalid, wrong issuer/audience, or token
178+
* malformed
179+
* - `TokenExpiredError` — past `exp`
180+
* - `NotBeforeError` — before `nbf`
181+
*
182+
* @throws {JsonWebTokenError} signature invalid or token malformed
183+
* @throws {TokenExpiredError} token has expired
184+
* @throws {NotBeforeError} token not yet valid
185+
*/
186+
verifyPeekAuthToken(token: string): PeekAuthTokenClaims {
187+
const payload = jwt.verify(token, this.jwtSecret, {
188+
issuer: PEEK_TOKEN_ISSUER,
189+
}) as RawPeekTokenPayload;
190+
191+
const { user: u } = payload;
192+
return {
193+
installId: payload.sub,
194+
displayVersion: payload.display_version,
195+
user: {
196+
email: u.email,
197+
id: u.id,
198+
isAdmin: u.is_admin,
199+
locale: u.locale,
200+
name: u.name,
201+
},
202+
};
203+
}
204+
155205
/**
156206
* Returns the {@link ProductService} for this install, bound to the shared
157207
* authenticated transport. The instance is created lazily and reused.

test/peek-access-service.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as jwt from "jsonwebtoken";
12
import { afterEach, describe, expect, it, vi } from "vitest";
23

34
import { AccountUserService } from "../src/internal/account-users/account-user-service.js";
@@ -258,6 +259,109 @@ describe("PeekAccessService.getProductService", () => {
258259
});
259260
});
260261

262+
const PEEK_REGISTRY_ISSUER = "app_registry_v2";
263+
const PEEK_REGISTRY_AUDIENCE = "Joken"; // still used when minting test tokens
264+
265+
const SAMPLE_USER_PAYLOAD = {
266+
email: "admin@peek.com",
267+
id: "null",
268+
is_admin: false,
269+
locale: "en",
270+
name: "Admin User",
271+
};
272+
273+
function mintRegistryToken(
274+
secret: string,
275+
overrides: Record<string, unknown> = {},
276+
): string {
277+
return jwt.sign(
278+
{ display_version: "0.0.11", user: SAMPLE_USER_PAYLOAD, ...overrides },
279+
secret,
280+
{
281+
subject: "8c1f32b4-ab3c-4e20-82b7-844ea9e03bc9",
282+
issuer: PEEK_REGISTRY_ISSUER,
283+
audience: PEEK_REGISTRY_AUDIENCE,
284+
jwtid: "7d3d42c5-5724-489e-8380-a33abfc14936",
285+
expiresIn: 60,
286+
notBefore: 0,
287+
},
288+
);
289+
}
290+
291+
describe("PeekAccessService.verifyPeekAuthToken", () => {
292+
it("returns fully typed claims from a valid Peek registry token", () => {
293+
const service = new PeekAccessService(REQUIRED_CONFIG);
294+
const token = mintRegistryToken(REQUIRED_CONFIG.jwtSecret);
295+
296+
const claims = service.verifyPeekAuthToken(token);
297+
298+
expect(claims.installId).toBe("8c1f32b4-ab3c-4e20-82b7-844ea9e03bc9");
299+
expect(claims.displayVersion).toBe("0.0.11");
300+
});
301+
302+
it("maps the nested user object to typed fields", () => {
303+
const service = new PeekAccessService(REQUIRED_CONFIG);
304+
const token = mintRegistryToken(REQUIRED_CONFIG.jwtSecret);
305+
306+
const { user } = service.verifyPeekAuthToken(token);
307+
308+
expect(user.email).toBe("admin@peek.com");
309+
expect(user.id).toBe("null");
310+
expect(user.isAdmin).toBe(false);
311+
expect(user.locale).toBe("en");
312+
expect(user.name).toBe("Admin User");
313+
});
314+
315+
it("maps is_admin: true correctly", () => {
316+
const service = new PeekAccessService(REQUIRED_CONFIG);
317+
const token = mintRegistryToken(REQUIRED_CONFIG.jwtSecret, {
318+
user: { ...SAMPLE_USER_PAYLOAD, is_admin: true },
319+
});
320+
321+
expect(service.verifyPeekAuthToken(token).user.isAdmin).toBe(true);
322+
});
323+
324+
it("throws on a token signed with a different secret", () => {
325+
const service = new PeekAccessService(REQUIRED_CONFIG);
326+
const token = mintRegistryToken("wrong-secret");
327+
328+
expect(() => service.verifyPeekAuthToken(token)).toThrow();
329+
});
330+
331+
it("throws on a token with a different issuer", () => {
332+
const service = new PeekAccessService(REQUIRED_CONFIG);
333+
const token = jwt.sign({ user: SAMPLE_USER_PAYLOAD }, REQUIRED_CONFIG.jwtSecret, {
334+
issuer: "wrong-issuer",
335+
audience: PEEK_REGISTRY_AUDIENCE,
336+
expiresIn: 60,
337+
});
338+
339+
expect(() => service.verifyPeekAuthToken(token)).toThrow();
340+
});
341+
342+
it("throws on an expired token", () => {
343+
const service = new PeekAccessService(REQUIRED_CONFIG);
344+
const token = jwt.sign(
345+
{ display_version: "0.0.11", user: SAMPLE_USER_PAYLOAD },
346+
REQUIRED_CONFIG.jwtSecret,
347+
{
348+
subject: "8c1f32b4-ab3c-4e20-82b7-844ea9e03bc9",
349+
issuer: PEEK_REGISTRY_ISSUER,
350+
audience: PEEK_REGISTRY_AUDIENCE,
351+
expiresIn: -1,
352+
},
353+
);
354+
355+
expect(() => service.verifyPeekAuthToken(token)).toThrow();
356+
});
357+
358+
it("throws on a malformed token string", () => {
359+
const service = new PeekAccessService(REQUIRED_CONFIG);
360+
361+
expect(() => service.verifyPeekAuthToken("not.a.jwt")).toThrow();
362+
});
363+
});
364+
261365
describe("PeekAccessService v2 mode", () => {
262366
it("uses the app-registry sandbox base URL by default", async () => {
263367
const { fetchFn, calls } = makeEmptyFetch();

0 commit comments

Comments
 (0)