Skip to content

Commit 46dec8e

Browse files
committed
feat: expose jwt helpers for app starter kit
1 parent 8222167 commit 46dec8e

6 files changed

Lines changed: 211 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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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+
/** JWT issuer — `"app_registry_v2"` for all Peek-issued tokens. */
20+
issuer: string;
21+
/** Unique JWT ID (`jti`). */
22+
jwtId: string;
23+
/** App display version at time of issuance. */
24+
displayVersion: string;
25+
/** Issued-at timestamp (Unix epoch seconds). */
26+
issuedAt: number;
27+
/** Expiration timestamp (Unix epoch seconds). */
28+
expiresAt: number;
29+
/** Not-before timestamp (Unix epoch seconds). */
30+
notBefore: number;
31+
/** Authenticated user context. */
32+
user: PeekAuthTokenUser;
33+
}

src/peek-access-service.ts

Lines changed: 60 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,19 @@ 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+
iss: string;
48+
jti: string;
49+
iat: number;
50+
exp: number;
51+
nbf: number;
52+
display_version: string;
53+
user: { email: string; id: string; is_admin: boolean; locale: string; name: string };
54+
}
4055

4156
/** Configuration for a {@link PeekAccessService} instance. */
4257
export interface PeekAccessServiceConfig {
@@ -104,6 +119,7 @@ export interface PeekAccessServiceConfig {
104119
export class PeekAccessService {
105120
private readonly client: GraphQLClient;
106121
private readonly productServiceOptions: ProductServiceOptions;
122+
private readonly jwtSecret: string;
107123
private productService?: ProductService;
108124
private accountUserService?: AccountUserService;
109125
private resourcePoolService?: ResourcePoolService;
@@ -124,6 +140,8 @@ export class PeekAccessService {
124140
requireNonEmpty(config.appId, "appId");
125141
if (!isV2) requireNonEmpty(config.gatewayKey ?? "", "gatewayKey");
126142

143+
this.jwtSecret = config.jwtSecret;
144+
127145
const logger = config.logger ?? noopLogger;
128146
const tokens = new TokenManager({
129147
secret: config.jwtSecret,
@@ -152,6 +170,48 @@ export class PeekAccessService {
152170
};
153171
}
154172

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

test/peek-access-service.test.ts

Lines changed: 109 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,114 @@ 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.issuer).toBe(PEEK_REGISTRY_ISSUER);
300+
expect(claims.jwtId).toBe("7d3d42c5-5724-489e-8380-a33abfc14936");
301+
expect(claims.displayVersion).toBe("0.0.11");
302+
expect(typeof claims.issuedAt).toBe("number");
303+
expect(typeof claims.expiresAt).toBe("number");
304+
expect(typeof claims.notBefore).toBe("number");
305+
});
306+
307+
it("maps the nested user object to typed fields", () => {
308+
const service = new PeekAccessService(REQUIRED_CONFIG);
309+
const token = mintRegistryToken(REQUIRED_CONFIG.jwtSecret);
310+
311+
const { user } = service.verifyPeekAuthToken(token);
312+
313+
expect(user.email).toBe("admin@peek.com");
314+
expect(user.id).toBe("null");
315+
expect(user.isAdmin).toBe(false);
316+
expect(user.locale).toBe("en");
317+
expect(user.name).toBe("Admin User");
318+
});
319+
320+
it("maps is_admin: true correctly", () => {
321+
const service = new PeekAccessService(REQUIRED_CONFIG);
322+
const token = mintRegistryToken(REQUIRED_CONFIG.jwtSecret, {
323+
user: { ...SAMPLE_USER_PAYLOAD, is_admin: true },
324+
});
325+
326+
expect(service.verifyPeekAuthToken(token).user.isAdmin).toBe(true);
327+
});
328+
329+
it("throws on a token signed with a different secret", () => {
330+
const service = new PeekAccessService(REQUIRED_CONFIG);
331+
const token = mintRegistryToken("wrong-secret");
332+
333+
expect(() => service.verifyPeekAuthToken(token)).toThrow();
334+
});
335+
336+
it("throws on a token with a different issuer", () => {
337+
const service = new PeekAccessService(REQUIRED_CONFIG);
338+
const token = jwt.sign({ user: SAMPLE_USER_PAYLOAD }, REQUIRED_CONFIG.jwtSecret, {
339+
issuer: "wrong-issuer",
340+
audience: PEEK_REGISTRY_AUDIENCE,
341+
expiresIn: 60,
342+
});
343+
344+
expect(() => service.verifyPeekAuthToken(token)).toThrow();
345+
});
346+
347+
it("throws on an expired token", () => {
348+
const service = new PeekAccessService(REQUIRED_CONFIG);
349+
const token = jwt.sign(
350+
{ display_version: "0.0.11", user: SAMPLE_USER_PAYLOAD },
351+
REQUIRED_CONFIG.jwtSecret,
352+
{
353+
subject: "8c1f32b4-ab3c-4e20-82b7-844ea9e03bc9",
354+
issuer: PEEK_REGISTRY_ISSUER,
355+
audience: PEEK_REGISTRY_AUDIENCE,
356+
expiresIn: -1,
357+
},
358+
);
359+
360+
expect(() => service.verifyPeekAuthToken(token)).toThrow();
361+
});
362+
363+
it("throws on a malformed token string", () => {
364+
const service = new PeekAccessService(REQUIRED_CONFIG);
365+
366+
expect(() => service.verifyPeekAuthToken("not.a.jwt")).toThrow();
367+
});
368+
});
369+
261370
describe("PeekAccessService v2 mode", () => {
262371
it("uses the app-registry sandbox base URL by default", async () => {
263372
const { fetchFn, calls } = makeEmptyFetch();

0 commit comments

Comments
 (0)