@storyteq/authnz-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,220 @@
1
+ # @storyteq/authnz-sdk
2
+
3
+ Backend SDK for Keycloak authentication and Access API authorization.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @storyteq/authnz-sdk @keycloak/keycloak-admin-client
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Service Token Provider (Machine-to-Machine)
14
+
15
+ Use this for backend services that need to authenticate as a service account:
16
+
17
+ ```typescript
18
+ import {
19
+ createServiceTokenProvider,
20
+ KEYCLOAK_URLS,
21
+ ACCESS_API_URLS,
22
+ REALMS,
23
+ } from "@storyteq/authnz-sdk"
24
+
25
+ const provider = createServiceTokenProvider({
26
+ keycloakBaseUrl: KEYCLOAK_URLS.production["europe-west1"],
27
+ keycloakRealm: REALMS.production,
28
+ accessApiBaseUrl: ACCESS_API_URLS.production["europe-west1"],
29
+ clientId: process.env.KEYCLOAK_CLIENT_ID,
30
+ clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
31
+ // Optional: set defaults for RPT requests
32
+ defaultPermissions: ["workspace:*"],
33
+ defaultAudience: ["platform"],
34
+ })
35
+
36
+ // Get a Keycloak service account token
37
+ const kcToken = await provider.keycloak()
38
+ console.log(kcToken.accessToken)
39
+
40
+ // Get an RPT (Resource Permission Token) from Access API
41
+ const rptToken = await provider.rpt({
42
+ permissions: ["workspace:123"],
43
+ audience: ["platform"],
44
+ })
45
+ console.log(rptToken.accessToken)
46
+ ```
47
+
48
+ ### User Token Provider (On-Behalf-Of)
49
+
50
+ Use this when you have a user's Keycloak JWT and need to exchange it for an RPT:
51
+
52
+ ```typescript
53
+ import { createUserTokenProvider, ACCESS_API_URLS } from "@storyteq/authnz-sdk"
54
+
55
+ const provider = createUserTokenProvider({
56
+ accessApiBaseUrl: ACCESS_API_URLS.production["europe-west1"],
57
+ })
58
+
59
+ // Exchange user's Keycloak JWT for RPT
60
+ const rptToken = await provider.rpt({
61
+ keycloakJwt: userKeycloakToken,
62
+ permissions: ["workspace:123"],
63
+ audience: ["platform"],
64
+ })
65
+
66
+ // Or exchange a static API token
67
+ const rptFromApiToken = await provider.rpt({
68
+ apiToken: userApiToken,
69
+ permissions: ["workspace:123"],
70
+ })
71
+ ```
72
+
73
+ ## API Reference
74
+
75
+ ### Types
76
+
77
+ #### TokenResult
78
+
79
+ ```typescript
80
+ interface TokenResult {
81
+ accessToken: string
82
+ expiresAt: Date
83
+ expiresIn: number
84
+ }
85
+ ```
86
+
87
+ #### ServiceTokenProviderConfig
88
+
89
+ ```typescript
90
+ interface ServiceTokenProviderConfig {
91
+ keycloakBaseUrl: string
92
+ keycloakRealm: string
93
+ accessApiBaseUrl: string
94
+ clientId: string
95
+ clientSecret: string
96
+ defaultPermissions?: string[]
97
+ defaultAudience?: string[]
98
+ logger?: Logger
99
+ }
100
+ ```
101
+
102
+ #### UserTokenProviderConfig
103
+
104
+ ```typescript
105
+ interface UserTokenProviderConfig {
106
+ accessApiBaseUrl: string
107
+ logger?: Logger
108
+ }
109
+ ```
110
+
111
+ ### Constants
112
+
113
+ - `KEYCLOAK_URLS` - Keycloak URLs by environment and region
114
+ - `ACCESS_API_URLS` - Access API URLs by environment and region
115
+ - `REALMS` - Keycloak realm names by environment
116
+
117
+ ### Error Handling
118
+
119
+ All errors are thrown as `AuthNZError` with specific error codes:
120
+
121
+ ```typescript
122
+ import { AuthNZError, ErrorCode } from "@storyteq/authnz-sdk"
123
+
124
+ try {
125
+ const token = await provider.rpt()
126
+ } catch (error) {
127
+ if (error instanceof AuthNZError) {
128
+ switch (error.code) {
129
+ case ErrorCode.KEYCLOAK_AUTH_FAILED:
130
+ // Handle Keycloak authentication failure
131
+ break
132
+ case ErrorCode.ACCESS_API_INVALID_TOKEN:
133
+ // Handle invalid token
134
+ break
135
+ case ErrorCode.ACCESS_API_USER_NOT_FOUND:
136
+ // Handle user not found
137
+ break
138
+ }
139
+ }
140
+ }
141
+ ```
142
+
143
+ ### Logging
144
+
145
+ Pass a logger to the provider configuration to enable debug logging:
146
+
147
+ ```typescript
148
+ const provider = createServiceTokenProvider({
149
+ // ... config
150
+ logger: {
151
+ debug: (msg, ctx) => console.debug(msg, ctx),
152
+ info: (msg, ctx) => console.info(msg, ctx),
153
+ warn: (msg, ctx) => console.warn(msg, ctx),
154
+ error: (msg, ctx) => console.error(msg, ctx),
155
+ },
156
+ })
157
+ ```
158
+
159
+ ## Caching Behavior
160
+
161
+ ### Service Token Provider
162
+
163
+ - Keycloak tokens are cached and reused until 15 seconds before expiry
164
+ - RPT tokens are cached per unique combination of permissions and audience
165
+ - On 401 errors, the cache is cleared and tokens are refreshed automatically
166
+
167
+ ### User Token Provider
168
+
169
+ - User tokens are NOT cached (each call makes a fresh request)
170
+ - This is intentional for security - user context should not be cached
171
+
172
+ ## Development
173
+
174
+ ```bash
175
+ # Install dependencies
176
+ pnpm install
177
+
178
+ # Run tests
179
+ pnpm test
180
+
181
+ # Type check
182
+ pnpm type-check
183
+
184
+ # Build
185
+ pnpm build
186
+
187
+ # Lint
188
+ pnpm lint
189
+ ```
190
+
191
+ ## CI/CD
192
+
193
+ ### Static Checks
194
+
195
+ On every MR and push to main, the CI runs:
196
+
197
+ - `pnpm lint` - ESLint checks
198
+ - `pnpm type-check` - TypeScript type checking
199
+ - `pnpm build` - Build verification
200
+ - `pnpm test` - Unit tests
201
+
202
+ ### Publishing to NPM
203
+
204
+ To publish a new version to NPM:
205
+
206
+ 1. Update the version in `package.json`
207
+ 2. Commit and merge to `main`
208
+ 3. Create a git tag from a commit on main:
209
+ ```bash
210
+ git tag authnz-sdk/1.0.0
211
+ ```
212
+ 4. Push the tag:
213
+ ```bash
214
+ git push origin authnz-sdk/1.0.0
215
+ ```
216
+
217
+ The CI will automatically:
218
+
219
+ - Build the package
220
+ - Publish to NPM
@@ -0,0 +1,15 @@
1
+ import type { TokenResult } from "../types.js";
2
+ export declare class TokenCache {
3
+ private keycloakToken;
4
+ private rptTokens;
5
+ private isTokenValid;
6
+ setKeycloakToken(token: TokenResult): void;
7
+ getKeycloakToken(bufferSeconds: number): TokenResult | null;
8
+ clearKeycloakToken(): void;
9
+ setRptToken(key: string, token: TokenResult): void;
10
+ getRptToken(key: string, bufferSeconds: number): TokenResult | null;
11
+ clearRptToken(key: string): void;
12
+ clearAllRptTokens(): void;
13
+ clearAll(): void;
14
+ }
15
+ //# sourceMappingURL=token-cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"token-cache.d.ts","sourceRoot":"","sources":["../../src/cache/token-cache.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAE9C,qBAAa,UAAU;IACrB,OAAO,CAAC,aAAa,CAA2B;IAChD,OAAO,CAAC,SAAS,CAAiC;IAElD,OAAO,CAAC,YAAY;IAKpB,gBAAgB,CAAC,KAAK,EAAE,WAAW,GAAG,IAAI;IAI1C,gBAAgB,CAAC,aAAa,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI;IAU3D,kBAAkB,IAAI,IAAI;IAI1B,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,IAAI;IAIlD,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI;IAWnE,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAIhC,iBAAiB,IAAI,IAAI;IAIzB,QAAQ,IAAI,IAAI;CAIjB"}
@@ -0,0 +1,48 @@
1
+ export class TokenCache {
2
+ constructor() {
3
+ this.keycloakToken = null;
4
+ this.rptTokens = new Map();
5
+ }
6
+ isTokenValid(token, bufferSeconds) {
7
+ const bufferMs = bufferSeconds * 1000;
8
+ return Date.now() < token.expiresAt.getTime() - bufferMs;
9
+ }
10
+ setKeycloakToken(token) {
11
+ this.keycloakToken = token;
12
+ }
13
+ getKeycloakToken(bufferSeconds) {
14
+ if (!this.keycloakToken) {
15
+ return null;
16
+ }
17
+ if (!this.isTokenValid(this.keycloakToken, bufferSeconds)) {
18
+ return null;
19
+ }
20
+ return this.keycloakToken;
21
+ }
22
+ clearKeycloakToken() {
23
+ this.keycloakToken = null;
24
+ }
25
+ setRptToken(key, token) {
26
+ this.rptTokens.set(key, token);
27
+ }
28
+ getRptToken(key, bufferSeconds) {
29
+ const token = this.rptTokens.get(key);
30
+ if (!token) {
31
+ return null;
32
+ }
33
+ if (!this.isTokenValid(token, bufferSeconds)) {
34
+ return null;
35
+ }
36
+ return token;
37
+ }
38
+ clearRptToken(key) {
39
+ this.rptTokens.delete(key);
40
+ }
41
+ clearAllRptTokens() {
42
+ this.rptTokens.clear();
43
+ }
44
+ clearAll() {
45
+ this.keycloakToken = null;
46
+ this.rptTokens.clear();
47
+ }
48
+ }
@@ -0,0 +1,18 @@
1
+ import type { Logger, RptRequestOptions, TokenResult } from "../types.js";
2
+ export interface AccessApiClientConfig {
3
+ baseUrl: string;
4
+ logger?: Logger | undefined;
5
+ }
6
+ export interface ExchangeOptions extends RptRequestOptions {
7
+ keycloakJwt?: string | undefined;
8
+ apiToken?: string | undefined;
9
+ }
10
+ export declare class AccessApiClient {
11
+ private readonly config;
12
+ constructor(config: AccessApiClientConfig);
13
+ exchangeForRpt(options: ExchangeOptions): Promise<TokenResult>;
14
+ getRptFromKeycloakToken(keycloakToken: string, options?: RptRequestOptions): Promise<TokenResult>;
15
+ private doRequest;
16
+ private mapStatusToErrorCode;
17
+ }
18
+ //# sourceMappingURL=access-api-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"access-api-client.d.ts","sourceRoot":"","sources":["../../src/clients/access-api-client.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAGzE,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAC5B;AAED,MAAM,WAAW,eAAgB,SAAQ,iBAAiB;IACxD,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAChC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAC9B;AAED,qBAAa,eAAe;IAC1B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAuB;gBAElC,MAAM,EAAE,qBAAqB;IAInC,cAAc,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,WAAW,CAAC;IAqC9D,uBAAuB,CAC3B,aAAa,EAAE,MAAM,EACrB,OAAO,CAAC,EAAE,iBAAiB,GAC1B,OAAO,CAAC,WAAW,CAAC;YAyBT,SAAS;IAiEvB,OAAO,CAAC,oBAAoB;CAa7B"}
@@ -0,0 +1,108 @@
1
+ import { AuthNZError, ErrorCode } from "../errors.js";
2
+ import { getTokenExpiry } from "../utils/jwt.js";
3
+ export class AccessApiClient {
4
+ constructor(config) {
5
+ this.config = config;
6
+ }
7
+ async exchangeForRpt(options) {
8
+ if (!options.keycloakJwt && !options.apiToken) {
9
+ throw new AuthNZError(ErrorCode.INVALID_CONFIG, "Either keycloakJwt or apiToken must be provided");
10
+ }
11
+ const body = new URLSearchParams();
12
+ if (options.keycloakJwt) {
13
+ body.append("jwt_keycloak_token", options.keycloakJwt);
14
+ }
15
+ else if (options.apiToken) {
16
+ body.append("api_token", options.apiToken);
17
+ }
18
+ if (options.permissions) {
19
+ for (const perm of options.permissions) {
20
+ body.append("permission", perm);
21
+ }
22
+ }
23
+ if (options.audience) {
24
+ for (const aud of options.audience) {
25
+ body.append("audience", aud);
26
+ }
27
+ }
28
+ return this.doRequest(`${this.config.baseUrl}/exchange`, {
29
+ method: "POST",
30
+ headers: {
31
+ "Content-Type": "application/x-www-form-urlencoded",
32
+ },
33
+ body: body.toString(),
34
+ });
35
+ }
36
+ async getRptFromKeycloakToken(keycloakToken, options) {
37
+ const body = new URLSearchParams();
38
+ if (options === null || options === void 0 ? void 0 : options.permissions) {
39
+ for (const perm of options.permissions) {
40
+ body.append("permission", perm);
41
+ }
42
+ }
43
+ if (options === null || options === void 0 ? void 0 : options.audience) {
44
+ for (const aud of options.audience) {
45
+ body.append("audience", aud);
46
+ }
47
+ }
48
+ return this.doRequest(`${this.config.baseUrl}/token`, {
49
+ method: "POST",
50
+ headers: {
51
+ "Content-Type": "application/x-www-form-urlencoded",
52
+ Authorization: `Bearer ${keycloakToken}`,
53
+ },
54
+ body: body.toString(),
55
+ });
56
+ }
57
+ async doRequest(url, init) {
58
+ var _a, _b, _c, _d, _e;
59
+ (_a = this.config.logger) === null || _a === void 0 ? void 0 : _a.debug("Calling Access API", { url });
60
+ let response;
61
+ try {
62
+ response = await fetch(url, init);
63
+ }
64
+ catch (error) {
65
+ (_b = this.config.logger) === null || _b === void 0 ? void 0 : _b.error("Access API unreachable", {
66
+ url,
67
+ error: error instanceof Error ? error.message : String(error),
68
+ });
69
+ throw new AuthNZError(ErrorCode.ACCESS_API_UNREACHABLE, "Failed to reach Access API", {
70
+ cause: error instanceof Error ? error : new Error(String(error)),
71
+ context: { url },
72
+ });
73
+ }
74
+ if (!response.ok) {
75
+ const errorText = await response.text();
76
+ (_c = this.config.logger) === null || _c === void 0 ? void 0 : _c.error("Access API request failed", {
77
+ url,
78
+ status: response.status,
79
+ error: errorText,
80
+ });
81
+ const errorCode = this.mapStatusToErrorCode(response.status);
82
+ throw new AuthNZError(errorCode, `Access API returned ${response.status}: ${errorText}`, { context: { url, status: response.status } });
83
+ }
84
+ const data = (await response.json());
85
+ const expiresAt = (_d = getTokenExpiry(data.access_token)) !== null && _d !== void 0 ? _d : new Date(Date.now() + data.expires_in * 1000);
86
+ (_e = this.config.logger) === null || _e === void 0 ? void 0 : _e.info("Access API token obtained", {
87
+ expiresIn: data.expires_in,
88
+ });
89
+ return {
90
+ accessToken: data.access_token,
91
+ expiresAt,
92
+ expiresIn: data.expires_in,
93
+ };
94
+ }
95
+ mapStatusToErrorCode(status) {
96
+ switch (status) {
97
+ case 400:
98
+ case 401:
99
+ return ErrorCode.ACCESS_API_INVALID_TOKEN;
100
+ case 404:
101
+ return ErrorCode.ACCESS_API_USER_NOT_FOUND;
102
+ case 422:
103
+ return ErrorCode.ACCESS_API_VALIDATION_ERROR;
104
+ default:
105
+ return ErrorCode.ACCESS_API_UNREACHABLE;
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,15 @@
1
+ import type { Logger, TokenResult } from "../types.js";
2
+ export interface KeycloakClientConfig {
3
+ baseUrl: string;
4
+ realm: string;
5
+ clientId: string;
6
+ clientSecret: string;
7
+ logger?: Logger | undefined;
8
+ }
9
+ export declare class KeycloakClient {
10
+ private readonly kcClient;
11
+ private readonly config;
12
+ constructor(config: KeycloakClientConfig);
13
+ getToken(): Promise<TokenResult>;
14
+ }
15
+ //# sourceMappingURL=keycloak-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keycloak-client.d.ts","sourceRoot":"","sources":["../../src/clients/keycloak-client.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAGtD,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAC5B;AAID,qBAAa,cAAc;IACzB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAe;IACxC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAsB;gBAEjC,MAAM,EAAE,oBAAoB;IAQlC,QAAQ,IAAI,OAAO,CAAC,WAAW,CAAC;CAgEvC"}
@@ -0,0 +1,60 @@
1
+ import KcAdminClient from "@keycloak/keycloak-admin-client";
2
+ import { AuthNZError, ErrorCode } from "../errors.js";
3
+ import { getTokenExpiry } from "../utils/jwt.js";
4
+ const DEFAULT_TOKEN_LIFETIME_SECONDS = 300;
5
+ export class KeycloakClient {
6
+ constructor(config) {
7
+ this.config = config;
8
+ this.kcClient = new KcAdminClient({
9
+ baseUrl: config.baseUrl,
10
+ realmName: config.realm,
11
+ });
12
+ }
13
+ async getToken() {
14
+ var _a, _b, _c, _d;
15
+ const credentials = {
16
+ grantType: "client_credentials",
17
+ clientId: this.config.clientId,
18
+ clientSecret: this.config.clientSecret,
19
+ };
20
+ try {
21
+ (_a = this.config.logger) === null || _a === void 0 ? void 0 : _a.debug("Authenticating with Keycloak", {
22
+ baseUrl: this.config.baseUrl,
23
+ realm: this.config.realm,
24
+ clientId: this.config.clientId,
25
+ });
26
+ await this.kcClient.auth(credentials);
27
+ const accessToken = await this.kcClient.getAccessToken();
28
+ if (!accessToken) {
29
+ throw new AuthNZError(ErrorCode.KEYCLOAK_AUTH_FAILED, "No access token returned from Keycloak");
30
+ }
31
+ const expiresAt = (_b = getTokenExpiry(accessToken)) !== null && _b !== void 0 ? _b : new Date(Date.now() + DEFAULT_TOKEN_LIFETIME_SECONDS * 1000);
32
+ const expiresIn = Math.floor((expiresAt.getTime() - Date.now()) / 1000);
33
+ (_c = this.config.logger) === null || _c === void 0 ? void 0 : _c.info("Keycloak token obtained", {
34
+ expiresIn,
35
+ expiresAt: expiresAt.toISOString(),
36
+ });
37
+ return {
38
+ accessToken,
39
+ expiresAt,
40
+ expiresIn,
41
+ };
42
+ }
43
+ catch (error) {
44
+ if (error instanceof AuthNZError) {
45
+ throw error;
46
+ }
47
+ (_d = this.config.logger) === null || _d === void 0 ? void 0 : _d.error("Keycloak authentication failed", {
48
+ error: error instanceof Error ? error.message : String(error),
49
+ });
50
+ throw new AuthNZError(ErrorCode.KEYCLOAK_AUTH_FAILED, "Failed to authenticate with Keycloak", {
51
+ cause: error instanceof Error ? error : new Error(String(error)),
52
+ context: {
53
+ baseUrl: this.config.baseUrl,
54
+ realm: this.config.realm,
55
+ clientId: this.config.clientId,
56
+ },
57
+ });
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,6 @@
1
+ import type { Environment, Region } from "./types.js";
2
+ export declare const KEYCLOAK_URLS: Record<Environment, Record<Region, string>>;
3
+ export declare const ACCESS_API_URLS: Record<Environment, Record<Region, string>>;
4
+ export declare const REALMS: Record<Environment, string>;
5
+ export declare const TOKEN_REFRESH_BUFFER_SECONDS = 15;
6
+ //# sourceMappingURL=constants.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,YAAY,CAAA;AAErD,eAAO,MAAM,aAAa,EAAE,MAAM,CAAC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CASrE,CAAA;AAED,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CASvE,CAAA;AAED,eAAO,MAAM,MAAM,EAAE,MAAM,CAAC,WAAW,EAAE,MAAM,CAG9C,CAAA;AAED,eAAO,MAAM,4BAA4B,KAAK,CAAA"}
@@ -0,0 +1,25 @@
1
+ export const KEYCLOAK_URLS = {
2
+ staging: {
3
+ "europe-west1": "https://staging.auth.europe-west1.storyteq.work",
4
+ "us-east4": "https://staging.auth.us-east4.storyteq.work",
5
+ },
6
+ production: {
7
+ "europe-west1": "https://auth.storyteq.com",
8
+ "us-east4": "https://auth.us-east4.storyteq.com",
9
+ },
10
+ };
11
+ export const ACCESS_API_URLS = {
12
+ staging: {
13
+ "europe-west1": "https://access.storyteq.work",
14
+ "us-east4": "https://access.us-east4.storyteq.work",
15
+ },
16
+ production: {
17
+ "europe-west1": "https://access.storyteq.com",
18
+ "us-east4": "https://access.us-east4.storyteq.com",
19
+ },
20
+ };
21
+ export const REALMS = {
22
+ staging: "storyteq.work",
23
+ production: "storyteq.com",
24
+ };
25
+ export const TOKEN_REFRESH_BUFFER_SECONDS = 15;
@@ -0,0 +1,20 @@
1
+ export declare enum ErrorCode {
2
+ KEYCLOAK_AUTH_FAILED = "KEYCLOAK_AUTH_FAILED",
3
+ KEYCLOAK_UNREACHABLE = "KEYCLOAK_UNREACHABLE",
4
+ ACCESS_API_UNREACHABLE = "ACCESS_API_UNREACHABLE",
5
+ ACCESS_API_INVALID_TOKEN = "ACCESS_API_INVALID_TOKEN",
6
+ ACCESS_API_USER_NOT_FOUND = "ACCESS_API_USER_NOT_FOUND",
7
+ ACCESS_API_VALIDATION_ERROR = "ACCESS_API_VALIDATION_ERROR",
8
+ INVALID_CONFIG = "INVALID_CONFIG",
9
+ TOKEN_REFRESH_FAILED = "TOKEN_REFRESH_FAILED"
10
+ }
11
+ export interface AuthNZErrorOptions {
12
+ cause?: Error;
13
+ context?: Record<string, unknown>;
14
+ }
15
+ export declare class AuthNZError extends Error {
16
+ readonly code: ErrorCode;
17
+ readonly context: Record<string, unknown> | undefined;
18
+ constructor(code: ErrorCode, message: string, options?: AuthNZErrorOptions);
19
+ }
20
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,oBAAY,SAAS;IACnB,oBAAoB,yBAAyB;IAC7C,oBAAoB,yBAAyB;IAC7C,sBAAsB,2BAA2B;IACjD,wBAAwB,6BAA6B;IACrD,yBAAyB,8BAA8B;IACvD,2BAA2B,gCAAgC;IAC3D,cAAc,mBAAmB;IACjC,oBAAoB,yBAAyB;CAC9C;AAED,MAAM,WAAW,kBAAkB;IACjC,KAAK,CAAC,EAAE,KAAK,CAAA;IACb,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAClC;AAED,qBAAa,WAAY,SAAQ,KAAK;IACpC,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAA;IACxB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAA;gBAEzC,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,kBAAkB;CAM3E"}
package/dist/errors.js ADDED
@@ -0,0 +1,19 @@
1
+ export var ErrorCode;
2
+ (function (ErrorCode) {
3
+ ErrorCode["KEYCLOAK_AUTH_FAILED"] = "KEYCLOAK_AUTH_FAILED";
4
+ ErrorCode["KEYCLOAK_UNREACHABLE"] = "KEYCLOAK_UNREACHABLE";
5
+ ErrorCode["ACCESS_API_UNREACHABLE"] = "ACCESS_API_UNREACHABLE";
6
+ ErrorCode["ACCESS_API_INVALID_TOKEN"] = "ACCESS_API_INVALID_TOKEN";
7
+ ErrorCode["ACCESS_API_USER_NOT_FOUND"] = "ACCESS_API_USER_NOT_FOUND";
8
+ ErrorCode["ACCESS_API_VALIDATION_ERROR"] = "ACCESS_API_VALIDATION_ERROR";
9
+ ErrorCode["INVALID_CONFIG"] = "INVALID_CONFIG";
10
+ ErrorCode["TOKEN_REFRESH_FAILED"] = "TOKEN_REFRESH_FAILED";
11
+ })(ErrorCode || (ErrorCode = {}));
12
+ export class AuthNZError extends Error {
13
+ constructor(code, message, options) {
14
+ super(message, { cause: options === null || options === void 0 ? void 0 : options.cause });
15
+ this.name = "AuthNZError";
16
+ this.code = code;
17
+ this.context = options === null || options === void 0 ? void 0 : options.context;
18
+ }
19
+ }
@@ -0,0 +1,8 @@
1
+ export declare const VERSION = "1.0.0";
2
+ export type { Environment, Logger, Region, RptRequestOptions, ServiceTokenProvider, ServiceTokenProviderConfig, TokenResult, UserTokenExchangeOptions, UserTokenProvider, UserTokenProviderConfig, } from "./types.js";
3
+ export { ACCESS_API_URLS, KEYCLOAK_URLS, REALMS, TOKEN_REFRESH_BUFFER_SECONDS, } from "./constants.js";
4
+ export type { AuthNZErrorOptions } from "./errors.js";
5
+ export { AuthNZError, ErrorCode } from "./errors.js";
6
+ export { createServiceTokenProvider } from "./providers/service-token-provider.js";
7
+ export { createUserTokenProvider } from "./providers/user-token-provider.js";
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,OAAO,UAAU,CAAA;AAG9B,YAAY,EACV,WAAW,EACX,MAAM,EACN,MAAM,EACN,iBAAiB,EACjB,oBAAoB,EACpB,0BAA0B,EAC1B,WAAW,EACX,wBAAwB,EACxB,iBAAiB,EACjB,uBAAuB,GACxB,MAAM,YAAY,CAAA;AAGnB,OAAO,EACL,eAAe,EACf,aAAa,EACb,MAAM,EACN,4BAA4B,GAC7B,MAAM,gBAAgB,CAAA;AAGvB,YAAY,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAA;AACrD,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAGpD,OAAO,EAAE,0BAA0B,EAAE,MAAM,uCAAuC,CAAA;AAClF,OAAO,EAAE,uBAAuB,EAAE,MAAM,oCAAoC,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ // @storyteq/authnz-sdk
2
+ // Unified authentication and authorization SDK
3
+ export const VERSION = "1.0.0";
4
+ // Constants
5
+ export { ACCESS_API_URLS, KEYCLOAK_URLS, REALMS, TOKEN_REFRESH_BUFFER_SECONDS, } from "./constants.js";
6
+ export { AuthNZError, ErrorCode } from "./errors.js";
7
+ // Providers
8
+ export { createServiceTokenProvider } from "./providers/service-token-provider.js";
9
+ export { createUserTokenProvider } from "./providers/user-token-provider.js";
@@ -0,0 +1,3 @@
1
+ import type { ServiceTokenProvider, ServiceTokenProviderConfig } from "../types.js";
2
+ export declare function createServiceTokenProvider(config: ServiceTokenProviderConfig): ServiceTokenProvider;
3
+ //# sourceMappingURL=service-token-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service-token-provider.d.ts","sourceRoot":"","sources":["../../src/providers/service-token-provider.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAEV,oBAAoB,EACpB,0BAA0B,EAE3B,MAAM,aAAa,CAAA;AAGpB,wBAAgB,0BAA0B,CACxC,MAAM,EAAE,0BAA0B,GACjC,oBAAoB,CA6EtB"}
@@ -0,0 +1,64 @@
1
+ import { TokenCache } from "../cache/token-cache.js";
2
+ import { AccessApiClient } from "../clients/access-api-client.js";
3
+ import { KeycloakClient } from "../clients/keycloak-client.js";
4
+ import { TOKEN_REFRESH_BUFFER_SECONDS } from "../constants.js";
5
+ import { AuthNZError, ErrorCode } from "../errors.js";
6
+ import { generateCacheKey } from "../utils/hash.js";
7
+ export function createServiceTokenProvider(config) {
8
+ const keycloakClient = new KeycloakClient({
9
+ baseUrl: config.keycloakBaseUrl,
10
+ realm: config.keycloakRealm,
11
+ clientId: config.clientId,
12
+ clientSecret: config.clientSecret,
13
+ logger: config.logger,
14
+ });
15
+ const accessApiClient = new AccessApiClient({
16
+ baseUrl: config.accessApiBaseUrl,
17
+ logger: config.logger,
18
+ });
19
+ const cache = new TokenCache();
20
+ async function keycloak() {
21
+ var _a, _b;
22
+ const cached = cache.getKeycloakToken(TOKEN_REFRESH_BUFFER_SECONDS);
23
+ if (cached) {
24
+ (_a = config.logger) === null || _a === void 0 ? void 0 : _a.debug("Using cached Keycloak token");
25
+ return cached;
26
+ }
27
+ (_b = config.logger) === null || _b === void 0 ? void 0 : _b.debug("Fetching new Keycloak token");
28
+ const token = await keycloakClient.getToken();
29
+ cache.setKeycloakToken(token);
30
+ return token;
31
+ }
32
+ async function rpt(options) {
33
+ var _a, _b, _c, _d;
34
+ const permissions = (_a = options === null || options === void 0 ? void 0 : options.permissions) !== null && _a !== void 0 ? _a : config.defaultPermissions;
35
+ const audience = (_b = options === null || options === void 0 ? void 0 : options.audience) !== null && _b !== void 0 ? _b : config.defaultAudience;
36
+ const cacheKey = generateCacheKey(permissions, audience);
37
+ const cachedRpt = cache.getRptToken(cacheKey, TOKEN_REFRESH_BUFFER_SECONDS);
38
+ if (cachedRpt) {
39
+ (_c = config.logger) === null || _c === void 0 ? void 0 : _c.debug("Using cached RPT token", { cacheKey });
40
+ return cachedRpt;
41
+ }
42
+ const kcToken = await keycloak();
43
+ try {
44
+ const rptToken = await accessApiClient.getRptFromKeycloakToken(kcToken.accessToken, { permissions, audience });
45
+ cache.setRptToken(cacheKey, rptToken);
46
+ return rptToken;
47
+ }
48
+ catch (error) {
49
+ // Retry on 401 with fresh keycloak token
50
+ if (error instanceof AuthNZError &&
51
+ error.code === ErrorCode.ACCESS_API_INVALID_TOKEN) {
52
+ (_d = config.logger) === null || _d === void 0 ? void 0 : _d.info("RPT request failed with 401, retrying with fresh Keycloak token");
53
+ cache.clearKeycloakToken();
54
+ const freshKcToken = await keycloakClient.getToken();
55
+ cache.setKeycloakToken(freshKcToken);
56
+ const rptToken = await accessApiClient.getRptFromKeycloakToken(freshKcToken.accessToken, { permissions, audience });
57
+ cache.setRptToken(cacheKey, rptToken);
58
+ return rptToken;
59
+ }
60
+ throw error;
61
+ }
62
+ }
63
+ return { keycloak, rpt };
64
+ }
@@ -0,0 +1,3 @@
1
+ import type { UserTokenProvider, UserTokenProviderConfig } from "../types.js";
2
+ export declare function createUserTokenProvider(config: UserTokenProviderConfig): UserTokenProvider;
3
+ //# sourceMappingURL=user-token-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"user-token-provider.d.ts","sourceRoot":"","sources":["../../src/providers/user-token-provider.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAGV,iBAAiB,EACjB,uBAAuB,EACxB,MAAM,aAAa,CAAA;AAEpB,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,uBAAuB,GAC9B,iBAAiB,CAuBnB"}
@@ -0,0 +1,23 @@
1
+ import { AccessApiClient } from "../clients/access-api-client.js";
2
+ export function createUserTokenProvider(config) {
3
+ const accessApiClient = new AccessApiClient({
4
+ baseUrl: config.accessApiBaseUrl,
5
+ logger: config.logger,
6
+ });
7
+ async function rpt(options) {
8
+ var _a;
9
+ (_a = config.logger) === null || _a === void 0 ? void 0 : _a.debug("Exchanging user token for RPT", {
10
+ hasKeycloakJwt: !!options.keycloakJwt,
11
+ hasApiToken: !!options.apiToken,
12
+ permissions: options.permissions,
13
+ audience: options.audience,
14
+ });
15
+ return accessApiClient.exchangeForRpt({
16
+ keycloakJwt: options.keycloakJwt,
17
+ apiToken: options.apiToken,
18
+ permissions: options.permissions,
19
+ audience: options.audience,
20
+ });
21
+ }
22
+ return { rpt };
23
+ }
@@ -0,0 +1,43 @@
1
+ export type Environment = "staging" | "production";
2
+ export type Region = "europe-west1" | "us-east4";
3
+ export interface TokenResult {
4
+ accessToken: string;
5
+ expiresAt: Date;
6
+ expiresIn: number;
7
+ }
8
+ export interface Logger {
9
+ debug(message: string, context?: Record<string, unknown>): void;
10
+ info(message: string, context?: Record<string, unknown>): void;
11
+ warn(message: string, context?: Record<string, unknown>): void;
12
+ error(message: string, context?: Record<string, unknown>): void;
13
+ }
14
+ export interface ServiceTokenProviderConfig {
15
+ keycloakBaseUrl: string;
16
+ keycloakRealm: string;
17
+ accessApiBaseUrl: string;
18
+ clientId: string;
19
+ clientSecret: string;
20
+ defaultPermissions?: string[] | undefined;
21
+ defaultAudience?: string[] | undefined;
22
+ logger?: Logger | undefined;
23
+ }
24
+ export interface UserTokenProviderConfig {
25
+ accessApiBaseUrl: string;
26
+ logger?: Logger | undefined;
27
+ }
28
+ export interface RptRequestOptions {
29
+ permissions?: string[] | undefined;
30
+ audience?: string[] | undefined;
31
+ }
32
+ export interface UserTokenExchangeOptions extends RptRequestOptions {
33
+ keycloakJwt?: string | undefined;
34
+ apiToken?: string | undefined;
35
+ }
36
+ export interface ServiceTokenProvider {
37
+ keycloak(): Promise<TokenResult>;
38
+ rpt(options?: RptRequestOptions): Promise<TokenResult>;
39
+ }
40
+ export interface UserTokenProvider {
41
+ rpt(options: UserTokenExchangeOptions): Promise<TokenResult>;
42
+ }
43
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,YAAY,CAAA;AAClD,MAAM,MAAM,MAAM,GAAG,cAAc,GAAG,UAAU,CAAA;AAEhD,MAAM,WAAW,WAAW;IAC1B,WAAW,EAAE,MAAM,CAAA;IACnB,SAAS,EAAE,IAAI,CAAA;IACf,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,MAAM;IACrB,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;IAC/D,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;IAC9D,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;IAC9D,KAAK,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAA;CAChE;AAED,MAAM,WAAW,0BAA0B;IACzC,eAAe,EAAE,MAAM,CAAA;IACvB,aAAa,EAAE,MAAM,CAAA;IACrB,gBAAgB,EAAE,MAAM,CAAA;IACxB,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,kBAAkB,CAAC,EAAE,MAAM,EAAE,GAAG,SAAS,CAAA;IACzC,eAAe,CAAC,EAAE,MAAM,EAAE,GAAG,SAAS,CAAA;IACtC,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAC5B;AAED,MAAM,WAAW,uBAAuB;IACtC,gBAAgB,EAAE,MAAM,CAAA;IACxB,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAC5B;AAED,MAAM,WAAW,iBAAiB;IAChC,WAAW,CAAC,EAAE,MAAM,EAAE,GAAG,SAAS,CAAA;IAClC,QAAQ,CAAC,EAAE,MAAM,EAAE,GAAG,SAAS,CAAA;CAChC;AAED,MAAM,WAAW,wBAAyB,SAAQ,iBAAiB;IACjE,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAChC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAC9B;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,IAAI,OAAO,CAAC,WAAW,CAAC,CAAA;IAChC,GAAG,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;CACvD;AAED,MAAM,WAAW,iBAAiB;IAChC,GAAG,CAAC,OAAO,EAAE,wBAAwB,GAAG,OAAO,CAAC,WAAW,CAAC,CAAA;CAC7D"}
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ export declare function generateCacheKey(permissions?: string[], audience?: string[]): string;
2
+ //# sourceMappingURL=hash.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hash.d.ts","sourceRoot":"","sources":["../../src/utils/hash.ts"],"names":[],"mappings":"AAAA,wBAAgB,gBAAgB,CAC9B,WAAW,CAAC,EAAE,MAAM,EAAE,EACtB,QAAQ,CAAC,EAAE,MAAM,EAAE,GAClB,MAAM,CAQR"}
@@ -0,0 +1,8 @@
1
+ export function generateCacheKey(permissions, audience) {
2
+ const sortedPermissions = [...(permissions !== null && permissions !== void 0 ? permissions : [])].sort();
3
+ const sortedAudience = [...(audience !== null && audience !== void 0 ? audience : [])].sort();
4
+ return JSON.stringify({
5
+ permissions: sortedPermissions,
6
+ audience: sortedAudience,
7
+ });
8
+ }
@@ -0,0 +1,11 @@
1
+ interface TokenPayload {
2
+ sub?: string;
3
+ exp?: number;
4
+ iat?: number;
5
+ [key: string]: unknown;
6
+ }
7
+ export declare function decodeToken(token: string): TokenPayload | null;
8
+ export declare function getTokenExpiry(token: string): Date | null;
9
+ export declare function isTokenExpired(token: string, bufferSeconds: number): boolean;
10
+ export {};
11
+ //# sourceMappingURL=jwt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jwt.d.ts","sourceRoot":"","sources":["../../src/utils/jwt.ts"],"names":[],"mappings":"AAEA,UAAU,YAAY;IACpB,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAA;CACvB;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CAO9D;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAMzD;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAO5E"}
@@ -0,0 +1,25 @@
1
+ import jwt from "jsonwebtoken";
2
+ export function decodeToken(token) {
3
+ try {
4
+ const decoded = jwt.decode(token, { json: true });
5
+ return decoded;
6
+ }
7
+ catch (_a) {
8
+ return null;
9
+ }
10
+ }
11
+ export function getTokenExpiry(token) {
12
+ const decoded = decodeToken(token);
13
+ if (!(decoded === null || decoded === void 0 ? void 0 : decoded.exp)) {
14
+ return null;
15
+ }
16
+ return new Date(decoded.exp * 1000);
17
+ }
18
+ export function isTokenExpired(token, bufferSeconds) {
19
+ const expiry = getTokenExpiry(token);
20
+ if (!expiry) {
21
+ return true;
22
+ }
23
+ const bufferMs = bufferSeconds * 1000;
24
+ return Date.now() >= expiry.getTime() - bufferMs;
25
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@storyteq/authnz-sdk",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Backend SDK for Keycloak authentication and Access API authorization",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "engines": {
9
+ "node": "^18.0.0 || ^22.0.0"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "import": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "peerDependencies": {
21
+ "@keycloak/keycloak-admin-client": ">=26.0.0"
22
+ },
23
+ "dependencies": {
24
+ "jsonwebtoken": "^9.0.2",
25
+ "zod": "^3.24.2"
26
+ },
27
+ "devDependencies": {
28
+ "@eslint/js": "^9.21.0",
29
+ "@keycloak/keycloak-admin-client": "^26.0.7",
30
+ "@tsconfig/node-lts": "^22.0.1",
31
+ "@tsconfig/strictest": "^2.0.5",
32
+ "@types/jsonwebtoken": "^9.0.9",
33
+ "@types/node": "^22.15.21",
34
+ "eslint": "^9.21.0",
35
+ "prettier": "^3.5.3",
36
+ "typescript": "^5.8.3",
37
+ "typescript-eslint": "^8.26.0",
38
+ "vitest": "^3.1.4",
39
+ "@storyteq/prettier-config": "1.0.0"
40
+ },
41
+ "prettier": "@storyteq/prettier-config",
42
+ "scripts": {
43
+ "build": "tsc -p ./tsconfig.dist.json",
44
+ "dev": "tsc --watch",
45
+ "test": "vitest run",
46
+ "test:watch": "vitest watch",
47
+ "type-check": "tsc --noEmit",
48
+ "lint": "eslint . && prettier --check .",
49
+ "lint:fix": "eslint . --fix",
50
+ "format": "prettier --write ."
51
+ }
52
+ }