@scalekit-sdk/node 1.0.14 → 2.0.1

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/src/scalekit.ts CHANGED
@@ -9,10 +9,12 @@ import DirectoryClient from './directory';
9
9
  import DomainClient from './domain';
10
10
  import OrganizationClient from './organization';
11
11
  import PasswordlessClient from './passwordless';
12
+ import UserClient from './user';
12
13
  import { IdpInitiatedLoginClaims, IdTokenClaim, User } from './types/auth';
13
- import { AuthenticationOptions, AuthenticationResponse, AuthorizationUrlOptions, GrantType } from './types/scalekit';
14
+ import { AuthenticationOptions, AuthenticationResponse, AuthorizationUrlOptions, GrantType, LogoutUrlOptions, RefreshTokenResponse ,TokenValidationOptions } from './types/scalekit';
14
15
 
15
16
  const authorizeEndpoint = "oauth/authorize";
17
+ const logoutEndpoint = "oidc/logout";
16
18
  const WEBHOOK_TOLERANCE_IN_SECONDS = 5 * 60; // 5 minutes
17
19
  const WEBHOOK_SIGNATURE_VERSION = "v1";
18
20
 
@@ -33,6 +35,7 @@ export default class ScalekitClient {
33
35
  readonly domain: DomainClient;
34
36
  readonly directory: DirectoryClient;
35
37
  readonly passwordless: PasswordlessClient;
38
+ readonly user: UserClient;
36
39
  constructor(
37
40
  envUrl: string,
38
41
  clientId: string,
@@ -67,6 +70,10 @@ export default class ScalekitClient {
67
70
  this.grpcConnect,
68
71
  this.coreClient
69
72
  );
73
+ this.user = new UserClient(
74
+ this.grpcConnect,
75
+ this.coreClient
76
+ );
70
77
  }
71
78
 
72
79
  /**
@@ -83,10 +90,14 @@ export default class ScalekitClient {
83
90
  * @param {string} options.provider Provider i.e. google, github, etc.
84
91
  * @param {string} options.codeChallenge Code challenge parameter in case of PKCE
85
92
  * @param {string} options.codeChallengeMethod Code challenge method parameter in case of PKCE
93
+ * @param {string} options.prompt Prompt parameter to control the authorization server's authentication behavior
86
94
  *
87
95
  * @example
88
96
  * const scalekit = new Scalekit(envUrl, clientId, clientSecret);
89
- * const authorizationUrl = scalekit.getAuthorizationUrl(redirectUri, { scopes: ['openid', 'profile'] });
97
+ * const authorizationUrl = scalekit.getAuthorizationUrl(redirectUri, {
98
+ * scopes: ['openid', 'profile'],
99
+ * prompt: 'create'
100
+ * });
90
101
  * @returns {string} authorization url
91
102
  */
92
103
  getAuthorizationUrl(
@@ -114,7 +125,8 @@ export default class ScalekitClient {
114
125
  ...(options.organizationId && { organization_id: options.organizationId }),
115
126
  ...(options.codeChallenge && { code_challenge: options.codeChallenge }),
116
127
  ...(options.codeChallengeMethod && { code_challenge_method: options.codeChallengeMethod }),
117
- ...(options.provider && { provider: options.provider })
128
+ ...(options.provider && { provider: options.provider }),
129
+ ...(options.prompt && { prompt: options.prompt })
118
130
  })
119
131
 
120
132
  return `${this.coreClient.envUrl}/${authorizeEndpoint}?${qs}`
@@ -141,7 +153,7 @@ export default class ScalekitClient {
141
153
  client_secret: this.coreClient.clientSecret,
142
154
  ...(options?.codeVerifier && { code_verifier: options.codeVerifier })
143
155
  }))
144
- const { id_token, access_token, expires_in } = res.data;
156
+ const { id_token, access_token, expires_in , refresh_token } = res.data;
145
157
  const claims = jose.decodeJwt<IdTokenClaim>(id_token);
146
158
  const user = <User>{};
147
159
  for (const [k, v] of Object.entries(claims)) {
@@ -154,7 +166,8 @@ export default class ScalekitClient {
154
166
  user,
155
167
  idToken: id_token,
156
168
  accessToken: access_token,
157
- expiresIn: expires_in
169
+ expiresIn: expires_in,
170
+ refreshToken: refresh_token
158
171
  }
159
172
  }
160
173
 
@@ -162,27 +175,56 @@ export default class ScalekitClient {
162
175
  * Get the idp initiated login claims
163
176
  *
164
177
  * @param {string} idpInitiatedLoginToken The idp_initiated_login query param from the URL
178
+ * @param {TokenValidationOptions} options Optional validation options for issuer and audience
165
179
  * @returns {object} Returns the idp initiated login claims
166
180
  */
167
- async getIdpInitiatedLoginClaims(idpInitiatedLoginToken: string): Promise<IdpInitiatedLoginClaims> {
168
- return this.validateToken<IdpInitiatedLoginClaims>(idpInitiatedLoginToken);
181
+ async getIdpInitiatedLoginClaims(idpInitiatedLoginToken: string, options?: TokenValidationOptions): Promise<IdpInitiatedLoginClaims> {
182
+ return this.validateToken<IdpInitiatedLoginClaims>(idpInitiatedLoginToken, options);
169
183
  }
170
184
 
171
185
  /**
172
- * Validates the access token.
186
+ * Validates the access token and returns a boolean result.
173
187
  *
174
188
  * @param {string} token The token to be validated.
189
+ * @param {TokenValidationOptions} options Optional validation options for issuer, audience, and scopes
175
190
  * @return {Promise<boolean>} Returns true if the token is valid, false otherwise.
176
191
  */
177
- async validateAccessToken(token: string): Promise<boolean> {
192
+ async validateAccessToken(token: string, options?: TokenValidationOptions): Promise<boolean> {
178
193
  try {
179
- await this.validateToken(token);
194
+ await this.validateToken(token, options);
180
195
  return true;
181
196
  } catch (_) {
182
197
  return false;
183
198
  }
184
199
  }
185
200
 
201
+
202
+
203
+ /**
204
+ * Returns the logout URL that can be used to log out the user.
205
+ * @param {LogoutUrlOptions} options Logout URL options
206
+ * @param {string} options.idTokenHint The ID Token previously issued to the client
207
+ * @param {string} options.postLogoutRedirectUri URL to redirect after logout
208
+ * @param {string} options.state Opaque value to maintain state between request and callback
209
+ * @returns {string} The logout URL
210
+ *
211
+ * @example
212
+ * const scalekit = new Scalekit(envUrl, clientId, clientSecret);
213
+ * const logoutUrl = scalekit.getLogoutUrl({
214
+ * postLogoutRedirectUri: 'https://example.com',
215
+ * state: 'some-state'
216
+ * });
217
+ */
218
+ getLogoutUrl(options?: LogoutUrlOptions): string {
219
+ const qs = QueryString.stringify({
220
+ ...(options?.idTokenHint && { id_token_hint: options.idTokenHint }),
221
+ ...(options?.postLogoutRedirectUri && { post_logout_redirect_uri: options.postLogoutRedirectUri }),
222
+ ...(options?.state && { state: options.state })
223
+ });
224
+
225
+ return `${this.coreClient.envUrl}/${logoutEndpoint}${qs ? `?${qs}` : ''}`;
226
+ }
227
+
186
228
  /**
187
229
  * Verifies the payload of the webhook
188
230
  *
@@ -217,24 +259,69 @@ export default class ScalekitClient {
217
259
  }
218
260
 
219
261
  /**
220
- * Validate token
262
+ * Validates a token and returns its payload if valid.
263
+ * Supports issuer, audience, and scope validation.
221
264
  *
222
265
  * @param {string} token The token to be validated
223
- * @return {Promise<T>} Returns the payload of the token
266
+ * @param {TokenValidationOptions} options Optional validation options for issuer, audience, and scopes
267
+ * @return {Promise<T>} Returns the token payload if valid
268
+ * @throws {Error} If token is invalid or missing required scopes
224
269
  */
225
- private async validateToken<T>(token: string): Promise<T> {
270
+ async validateToken<T>(token: string, options?: TokenValidationOptions): Promise<T> {
226
271
  await this.coreClient.getJwks();
227
272
  const jwks = jose.createLocalJWKSet({
228
273
  keys: this.coreClient.keys
229
274
  })
230
275
  try {
231
- const { payload } = await jose.jwtVerify<T>(token, jwks);
276
+ const { payload } = await jose.jwtVerify<T>(token, jwks, {
277
+ ...(options?.issuer && { issuer: options.issuer }),
278
+ ...(options?.audience && { audience: options.audience })
279
+ });
280
+
281
+ if (options?.requiredScopes && options.requiredScopes.length > 0) {
282
+ this.verifyScopes(token, options.requiredScopes);
283
+ }
284
+
232
285
  return payload;
233
286
  } catch (_) {
234
287
  throw new Error("Invalid token");
235
288
  }
236
289
  }
237
290
 
291
+ /**
292
+ * Verify that the token contains the required scopes
293
+ *
294
+ * @param {string} token The token to verify
295
+ * @param {string[]} requiredScopes The scopes that must be present in the token
296
+ * @return {boolean} Returns true if all required scopes are present
297
+ * @throws {Error} If required scopes are missing, with details about which scopes are missing
298
+ */
299
+ verifyScopes(token: string, requiredScopes: string[]): boolean {
300
+ const payload = jose.decodeJwt(token);
301
+ const scopes = this.extractScopesFromPayload(payload);
302
+
303
+ const missingScopes = requiredScopes.filter(scope => !scopes.includes(scope));
304
+
305
+ if (missingScopes.length > 0) {
306
+ throw new Error(`Token missing required scopes: ${missingScopes.join(', ')}`);
307
+ }
308
+
309
+ return true;
310
+ }
311
+
312
+ /**
313
+ * Extract scopes from token payload
314
+ *
315
+ * @param {any} payload The token payload
316
+ * @return {string[]} Array of scopes found in the token
317
+ */
318
+ private extractScopesFromPayload(payload: Record<string, any>): string[] {
319
+ const scopes = payload.scopes;
320
+ return Array.isArray(scopes)
321
+ ? scopes.filter((scope) => !!scope.trim?.())
322
+ : [];
323
+ }
324
+
238
325
  /**
239
326
  * Verify the timestamp
240
327
  *
@@ -267,6 +354,48 @@ export default class ScalekitClient {
267
354
  private computeSignature(secretBytes: Buffer, data: string): string {
268
355
  return crypto.createHmac('sha256', secretBytes).update(data).digest('base64');
269
356
  }
270
- }
271
357
 
358
+ /**
359
+ * Refresh access token using a refresh token
360
+ * @param {string} refreshToken The refresh token to use
361
+ * @returns {Promise<RefreshTokenResponse>} Returns new access token, refresh token and other details
362
+ * @throws {Error} When authentication fails or response data is invalid
363
+ */
364
+ async refreshAccessToken(refreshToken: string): Promise<RefreshTokenResponse> {
365
+ if (!refreshToken) {
366
+ throw new Error("Refresh token is required");
367
+ }
368
+
369
+ let res;
370
+ try {
371
+ res = await this.coreClient.authenticate(QueryString.stringify({
372
+ grant_type: GrantType.RefreshToken,
373
+ client_id: this.coreClient.clientId,
374
+ client_secret: this.coreClient.clientSecret,
375
+ refresh_token: refreshToken
376
+ }));
377
+ } catch (error) {
378
+ throw new Error(`Failed to refresh token: ${error instanceof Error ? error.message : 'Unknown error'}`);
379
+ }
380
+
381
+ if (!res || !res.data) {
382
+ throw new Error("Invalid response from authentication server");
383
+ }
384
+
385
+ const { access_token, refresh_token } = res.data;
386
+
387
+ // Validate that all required properties exist
388
+ if (!access_token) {
389
+ throw new Error("Missing access_token in authentication response");
390
+ }
391
+ if (!refresh_token) {
392
+ throw new Error("Missing refresh_token in authentication response");
393
+ }
394
+
395
+ return {
396
+ accessToken: access_token,
397
+ refreshToken: refresh_token
398
+ };
399
+ }
400
+ }
272
401
 
package/src/types/auth.ts CHANGED
@@ -62,6 +62,7 @@ export type TokenResponse = {
62
62
  access_token: string;
63
63
  id_token: string;
64
64
  expires_in: number;
65
+ refresh_token: string;
65
66
  }
66
67
 
67
68
  export type IdpInitiatedLoginClaims ={
@@ -17,15 +17,34 @@ export type AuthorizationUrlOptions = {
17
17
  codeChallenge?: string;
18
18
  codeChallengeMethod?: string;
19
19
  provider?: string;
20
+ prompt?: string;
20
21
  }
21
22
 
22
23
  export type AuthenticationOptions = {
23
24
  codeVerifier?: string;
24
25
  }
25
26
 
27
+ export type TokenValidationOptions = {
28
+ issuer?: string;
29
+ audience?: string[];
30
+ requiredScopes?: string[];
31
+ }
32
+
26
33
  export type AuthenticationResponse = {
27
34
  user: User;
28
35
  idToken: string;
29
36
  accessToken: string;
30
37
  expiresIn: number;
38
+ refreshToken: string;
39
+ }
40
+
41
+ export type RefreshTokenResponse = {
42
+ accessToken: string;
43
+ refreshToken: string;
44
+ }
45
+
46
+ export interface LogoutUrlOptions {
47
+ idTokenHint?: string;
48
+ postLogoutRedirectUri?: string;
49
+ state?: string;
31
50
  }
@@ -0,0 +1,21 @@
1
+ export interface CreateUserRequest {
2
+ email: string;
3
+ externalId?: string;
4
+ phoneNumber?: string;
5
+ userProfile?: {
6
+ firstName?: string;
7
+ lastName?: string;
8
+ };
9
+ metadata?: Record<string, string>;
10
+ sendActivationEmail?: boolean;
11
+ }
12
+
13
+ export interface UpdateUserRequest {
14
+ email?: string;
15
+ externalId?: string;
16
+ userProfile?: {
17
+ firstName?: string;
18
+ lastName?: string;
19
+ };
20
+ metadata?: Record<string, string>;
21
+ }
package/src/user.ts ADDED
@@ -0,0 +1,291 @@
1
+ import { Empty, PartialMessage } from '@bufbuild/protobuf';
2
+ import { PromiseClient } from '@connectrpc/connect';
3
+ import GrpcConnect from './connect';
4
+ import CoreClient from './core';
5
+ import { UserService } from './pkg/grpc/scalekit/v1/users/users_connect';
6
+ import {
7
+ CreateUserAndMembershipRequest,
8
+ CreateUserAndMembershipResponse,
9
+ DeleteUserRequest,
10
+ GetUserRequest,
11
+ GetUserResponse,
12
+ ListUsersRequest,
13
+ ListUsersResponse,
14
+ UpdateUserRequest,
15
+ UpdateUserResponse,
16
+ User,
17
+ UpdateUser,
18
+ CreateUser,
19
+ CreateUserProfile,
20
+ CreateMembershipRequest,
21
+ CreateMembershipResponse,
22
+ DeleteMembershipRequest,
23
+ UpdateMembershipRequest,
24
+ UpdateMembershipResponse,
25
+ ListOrganizationUsersRequest,
26
+ ListOrganizationUsersResponse,
27
+ CreateMembership,
28
+ UpdateMembership
29
+ } from './pkg/grpc/scalekit/v1/users/users_pb';
30
+ import { CreateUserRequest, UpdateUserRequest as UpdateUserRequestType } from './types/user';
31
+
32
+ export default class UserClient {
33
+ private client: PromiseClient<typeof UserService>;
34
+
35
+ constructor(
36
+ private readonly grpcConnect: GrpcConnect,
37
+ private readonly coreClient: CoreClient
38
+ ) {
39
+ this.client = this.grpcConnect.createClient(UserService);
40
+ }
41
+
42
+ /**
43
+ * Create a new user and add them to an organization
44
+ * @param {string} organizationId The organization id
45
+ * @param {CreateUserRequest} options The user creation options
46
+ * @returns {Promise<CreateUserAndMembershipResponse>} The created user
47
+ */
48
+ async createUserAndMembership(organizationId: string, options: CreateUserRequest): Promise<CreateUserAndMembershipResponse> {
49
+ if (!organizationId) {
50
+ throw new Error('organizationId is required');
51
+ }
52
+ if (!options.email) {
53
+ throw new Error('email is required');
54
+ }
55
+
56
+ const user = new CreateUser({
57
+ email: options.email,
58
+ userProfile: options.userProfile ? new CreateUserProfile({
59
+ firstName: options.userProfile.firstName,
60
+ lastName: options.userProfile.lastName
61
+ }) : undefined,
62
+ metadata: options.metadata
63
+ });
64
+
65
+ const request: PartialMessage<CreateUserAndMembershipRequest> = {
66
+ organizationId,
67
+ user
68
+ };
69
+
70
+ if (options.sendActivationEmail !== undefined) {
71
+ request.sendActivationEmail = options.sendActivationEmail;
72
+ }
73
+
74
+ const response = await this.coreClient.connectExec(
75
+ this.client.createUserAndMembership,
76
+ request
77
+ );
78
+
79
+ if (!response.user) {
80
+ throw new Error('Failed to create user');
81
+ }
82
+
83
+ return response;
84
+ }
85
+
86
+ /**
87
+ * Get a user by id
88
+ * @param {string} userId The user id
89
+ * @returns {Promise<GetUserResponse>} The user
90
+ */
91
+ async getUser(userId: string): Promise<GetUserResponse> {
92
+ return this.coreClient.connectExec(
93
+ this.client.getUser,
94
+ {
95
+ identities: {
96
+ case: 'id',
97
+ value: userId
98
+ }
99
+ }
100
+ );
101
+ }
102
+
103
+ /**
104
+ * List users with pagination
105
+ * @param {object} options The pagination options
106
+ * @param {number} options.pageSize The page size
107
+ * @param {string} options.pageToken The page token
108
+ * @returns {Promise<ListUsersResponse>} The list of users
109
+ */
110
+ async listUsers(options?: {
111
+ pageSize?: number,
112
+ pageToken?: string
113
+ }): Promise<ListUsersResponse> {
114
+ return this.coreClient.connectExec(
115
+ this.client.listUsers,
116
+ {
117
+ pageSize: options?.pageSize,
118
+ pageToken: options?.pageToken
119
+ }
120
+ );
121
+ }
122
+
123
+ /**
124
+ * Update a user
125
+ * @param {string} userId The user id
126
+ * @param {UpdateUserRequestType} options The update options
127
+ * @returns {Promise<UpdateUserResponse>} The updated user
128
+ */
129
+ async updateUser(userId: string, options: UpdateUserRequestType): Promise<UpdateUserResponse> {
130
+ const updateUser = new UpdateUser({
131
+ userProfile: options.userProfile ? {
132
+ firstName: options.userProfile.firstName,
133
+ lastName: options.userProfile.lastName
134
+ } : undefined,
135
+ metadata: options.metadata
136
+ });
137
+
138
+ return this.coreClient.connectExec(
139
+ this.client.updateUser,
140
+ {
141
+ identities: {
142
+ case: 'id',
143
+ value: userId
144
+ },
145
+ user: updateUser
146
+ }
147
+ );
148
+ }
149
+
150
+ /**
151
+ * Delete a user
152
+ * @param {string} userId The user id
153
+ * @returns {Promise<Empty>} Empty response
154
+ */
155
+ async deleteUser(userId: string): Promise<Empty> {
156
+ return this.coreClient.connectExec(
157
+ this.client.deleteUser,
158
+ {
159
+ identities: {
160
+ case: 'id',
161
+ value: userId
162
+ }
163
+ }
164
+ );
165
+ }
166
+
167
+ /**
168
+ * Create a membership for a user in an organization
169
+ * @param {string} organizationId The organization id
170
+ * @param {string} userId The user id
171
+ * @param {object} options The membership options
172
+ * @param {string[]} options.roles The roles to assign
173
+ * @param {Record<string, string>} options.metadata Optional metadata
174
+ * @param {boolean} options.sendActivationEmail Whether to send activation email
175
+ * @returns {Promise<CreateMembershipResponse>} The response with updated user
176
+ */
177
+ async createMembership(
178
+ organizationId: string,
179
+ userId: string,
180
+ options: {
181
+ roles?: string[],
182
+ metadata?: Record<string, string>,
183
+ sendActivationEmail?: boolean
184
+ } = {}
185
+ ): Promise<CreateMembershipResponse> {
186
+ const membership = new CreateMembership({
187
+ roles: options.roles?.map(role => ({ name: role })) || [],
188
+ metadata: options.metadata || {}
189
+ });
190
+
191
+ const request: PartialMessage<CreateMembershipRequest> = {
192
+ organizationId,
193
+ identities: {
194
+ case: 'id',
195
+ value: userId
196
+ },
197
+ membership
198
+ };
199
+
200
+ if (options.sendActivationEmail !== undefined) {
201
+ request.sendActivationEmail = options.sendActivationEmail;
202
+ }
203
+
204
+ return this.coreClient.connectExec(
205
+ this.client.createMembership,
206
+ request
207
+ );
208
+ }
209
+
210
+ /**
211
+ * Delete a user's membership from an organization
212
+ * @param {string} organizationId The organization id
213
+ * @param {string} userId The user id
214
+ * @returns {Promise<Empty>} Empty response
215
+ */
216
+ async deleteMembership(
217
+ organizationId: string,
218
+ userId: string
219
+ ): Promise<Empty> {
220
+ return this.coreClient.connectExec(
221
+ this.client.deleteMembership,
222
+ {
223
+ organizationId,
224
+ identities: {
225
+ case: 'id',
226
+ value: userId
227
+ }
228
+ }
229
+ );
230
+ }
231
+
232
+ /**
233
+ * Update a user's membership in an organization
234
+ * @param {string} organizationId The organization id
235
+ * @param {string} userId The user id
236
+ * @param {object} options The update options
237
+ * @param {string[]} options.roles The roles to assign
238
+ * @param {Record<string, string>} options.metadata Optional metadata
239
+ * @returns {Promise<UpdateMembershipResponse>} The response with updated user
240
+ */
241
+ async updateMembership(
242
+ organizationId: string,
243
+ userId: string,
244
+ options: {
245
+ roles?: string[],
246
+ metadata?: Record<string, string>
247
+ } = {}
248
+ ): Promise<UpdateMembershipResponse> {
249
+ const membership = new UpdateMembership({
250
+ roles: options.roles?.map(role => ({ name: role })) || [],
251
+ metadata: options.metadata || {}
252
+ });
253
+
254
+ return this.coreClient.connectExec(
255
+ this.client.updateMembership,
256
+ {
257
+ organizationId,
258
+ identities: {
259
+ case: 'id',
260
+ value: userId
261
+ },
262
+ membership
263
+ }
264
+ );
265
+ }
266
+
267
+ /**
268
+ * List users in an organization with pagination
269
+ * @param {string} organizationId The organization id
270
+ * @param {object} options The pagination options
271
+ * @param {number} options.pageSize The page size
272
+ * @param {string} options.pageToken The page token
273
+ * @returns {Promise<ListOrganizationUsersResponse>} The list of users in the organization
274
+ */
275
+ async listOrganizationUsers(
276
+ organizationId: string,
277
+ options?: {
278
+ pageSize?: number,
279
+ pageToken?: string
280
+ }
281
+ ): Promise<ListOrganizationUsersResponse> {
282
+ return this.coreClient.connectExec(
283
+ this.client.listOrganizationUsers,
284
+ {
285
+ organizationId,
286
+ pageSize: options?.pageSize,
287
+ pageToken: options?.pageToken
288
+ }
289
+ );
290
+ }
291
+ }