Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ useEffect(() => {
- `sdk.isInOAuthCallback()` - Check if processing OAuth redirect
- `sdk.completeOAuth()` - Manually complete OAuth (advanced use)
- `sdk.getToken()` - Get the logged-in user's access token
- `sdk.getTokenIdentity()` - Get identity claims (email, firstName, lastName, preferredUsername, name) decoded from the current JWT access token
- `sdk.logout()` - Logout and clear all authentication state (requires re-initialization to authenticate again)
- `sdk.updateToken()` - Inject a refreshed token into the SDK instance (useful for backend services managing token lifecycle)

Expand Down
11 changes: 11 additions & 0 deletions src/core/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,14 @@ export interface OAuthContext {
tenantName: string;
scope: string;
}

/**
* Identity claims decoded from the current JWT access token.
*/
export interface TokenIdentity {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Can we rename this as well?
  2. Is there any other info we get from the token that we can put here as well - like user-id ?
  3. Why are all fields optional?

email?: string;
firstName?: string;
lastName?: string;
preferredUsername?: string;
name?: string;
}
2 changes: 1 addition & 1 deletion src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@

export { UiPath } from './uipath';
export type { UiPathSDKConfig } from './config/sdk-config';
export type { TokenInfo } from './auth/types';
export type { TokenInfo, TokenIdentity } from './auth/types';
export * from './errors';

// Pagination (common across all services)
Expand Down
9 changes: 8 additions & 1 deletion src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/

import type { BaseConfig } from './config/sdk-config';
import type { TokenInfo } from './auth/types';
import type { TokenInfo, TokenIdentity } from './auth/types';

export interface IUiPath {
/** Read-only configuration for the SDK instance */
Expand Down Expand Up @@ -47,6 +47,13 @@ export interface IUiPath {
*/
getToken(): string | undefined;

/**
* Retrieves identity claims (email, firstName, lastName, preferredUsername, name)
* of the currently authenticated user by decoding the JWT access token.
* Does not work with PAT tokens.
*/
getTokenIdentity(): TokenIdentity;

/**
* Logout from the SDK, clearing all authentication state.
* After calling this method, the user will need to re-initialize to authenticate again.
Expand Down
63 changes: 62 additions & 1 deletion src/core/uipath.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { UiPathConfig } from './config/config';
import { ExecutionContext } from './context/execution';
import { AuthService } from './auth/service';
import { TokenInfo } from './auth/types';
import { TokenInfo, TokenIdentity } from './auth/types';
import { UiPathSDKConfig, PartialUiPathConfig, BaseConfig, hasOAuthConfig, hasSecretConfig } from './config/sdk-config';
import { validateConfig, normalizeBaseUrl, isCompleteConfig } from './config/config-utils';
import { telemetryClient, trackEvent } from './telemetry';
import { SDKInternalsRegistry } from './internals';
import { loadFromMetaTags } from './config/runtime';
import type { IUiPath } from './types';
import { isInActionCenter } from '../utils/platform';
import { decodeBase64 } from '../utils/encoding/base64';
import { AuthenticationError, ValidationError } from './errors';

/**
* UiPath - Core SDK class for authentication and configuration management.
Expand Down Expand Up @@ -238,6 +240,65 @@ export class UiPath implements IUiPath {
return this.#authService?.getToken();
}

/**
* Retrieves identity claims of the currently authenticated user by decoding
* the JWT access token held in memory. Does not make an API call.
*
* Returns the following camelCase claims when present on the token:
* `email`, `firstName`, `lastName`, `preferredUsername`, `name`.
Comment thread
vnaren23 marked this conversation as resolved.
*
* @returns The {@link TokenIdentity} extracted from the JWT payload.
* @throws {@link AuthenticationError} If the user is not authenticated.
* @throws {@link ValidationError} If the token is malformed or its payload cannot be decoded.
*
* @example
* ```typescript
* const sdk = new UiPath({ ...config });
* await sdk.initialize();
*
* const identity = sdk.getTokenIdentity();
* console.log(identity.email, identity.name);
* ```
*/
public getTokenIdentity(): TokenIdentity {
Comment thread
vnaren23 marked this conversation as resolved.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getTokenIdentity seems very technical name. IMO it should be getUserDetails

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getUserDetails might seem like we are querying our backend for user info. Since all we are doing is decoding a token, used getTokenIdentity

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

then by this logic, even getToken method can also be interpreted by someone in the same manner

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, even this method is on top of getToken. Just a helper for easier SDK usage, nothing more.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from a user's POV, I dont like this name

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm open to changing. Maybe getTokenUserDetails ?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about getLoggedInUser/ getAuthenticatedUser?
Since we have isAuthenticated method already, getAuthenticatedUser makes sense.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getUserDetails might seem like we are querying our backend for user info. Since all we are doing is decoding a token, used getTokenIdentity

@vnaren23 question - Why does the user have to care about how the details are being fetched. If anything, shouldn't the name describe what the user gets, and not how they get it?
The "users might think it's a network call" worry could be solved with a JSDoc comment?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There actually could be a getUserDetails API call which we might be masking due to this implementation. Hence my hesitance in using getUserDetails. getAuthenticatedUser is more in tune to getting details from token

if (!this.isAuthenticated()) {
throw new AuthenticationError({
message: 'User is not authenticated. Call initialize() before getTokenIdentity().'
});
}

const token = this.getToken();
if (!token) {
Comment thread
vnaren23 marked this conversation as resolved.
throw new AuthenticationError({
message: 'User is not authenticated. Call initialize() before getTokenIdentity().'
});
}

const segments = token.split('.');
if (segments.length !== 3) {
throw new ValidationError({ message: 'Invalid JWT token format.' });
Comment thread
vnaren23 marked this conversation as resolved.
Comment thread
vnaren23 marked this conversation as resolved.
}

let claims: Record<string, unknown>;
try {
// Convert base64url to base64 and pad to a multiple of 4.
let payload = segments[1].replace(/-/g, '+').replace(/_/g, '/');
const paddingLength = (4 - (payload.length % 4)) % 4;
payload = payload + '='.repeat(paddingLength);
Comment on lines +284 to +287
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we create a helper for this in base64.ts ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are not doing any decoding in these lines.

claims = JSON.parse(decodeBase64(payload));
Comment thread
maninder-uipath marked this conversation as resolved.
} catch {
throw new ValidationError({ message: 'Failed to decode JWT token payload.' });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be ServerError, not ValidationError. Per the conventions:

NEVER use ValidationError for server-side issuesValidationError is for user input validation only (missing required params, invalid option values). Server-side failures like failed JSON parsing of API responses must use ServerError.

A JWT token is issued by the auth server — JSON.parse(decodeBase64(payload)) failing is directly analogous to the "failed to parse output as JSON" example in the rules, and uses the same JSON.parse pattern. Use ServerError here:

} catch {
  throw new ServerError({ message: 'Failed to decode JWT token payload.' });
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessarily. SDK takes both OAuth (JWT) and PAT token (not JWT). Since we cannot decode a PAT token, this can remain as validation error.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the JWT being decoded didn't come from the user, it comes from the identity. The caller of the method passes nothing. So how is validation error a right fit?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can set token, could be a PAT token or anything random.

Comment thread
vnaren23 marked this conversation as resolved.
Comment thread
vnaren23 marked this conversation as resolved.
}

return {
email: claims.email as string | undefined,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These casts (claims.email as string | undefined) are type assertions without runtime validation. If a JWT claim is unexpectedly a number (e.g., "name": 42), the cast silently returns the wrong type. Prefer a narrow helper:

const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined);

return {
  email: str(claims.email),
  firstName: str(claims.first_name),
  lastName: str(claims.last_name),
  preferredUsername: str(claims.preferred_username),
  name: str(claims.name),
};

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

None of these values are ever a non string.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit/thought: if you're using unknown, do the work unknown exists to make you do. The whole point of it is that it forces a check before narrowing.

Comment thread
vnaren23 marked this conversation as resolved.
firstName: claims.first_name as string | undefined,
lastName: claims.last_name as string | undefined,
preferredUsername: claims.preferred_username as string | undefined,
name: claims.name as string | undefined
Comment thread
vnaren23 marked this conversation as resolved.
};
}

/**
* Logout from the SDK, clearing all authentication state.
* After calling this method, the user will need to re-initialize to authenticate again.
Expand Down
128 changes: 124 additions & 4 deletions tests/unit/core/uipath.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,28 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { UiPath } from '../../../src/core/uipath';
import { UiPathConfig } from '../../../src/core/config/config';
import { ExecutionContext } from '../../../src/core/context/execution';
import { AuthenticationError, ValidationError } from '../../../src/core/errors';
import { getConfig, getContext, getTokenManager, getPrivateSDK } from '../../utils/setup';
import { TEST_CONSTANTS } from '../../utils/constants/common';

// ===== MOCKING =====
const mockAuthState = {
token: 'mock-access-token' as string | undefined,
hasValidToken: true
};

const mockTokenManager = {
getToken: () => 'mock-access-token',
hasValidToken: () => true
getToken: () => mockAuthState.token,
hasValidToken: () => mockAuthState.hasValidToken
};

const mockLogout = vi.fn();

vi.mock('../../../src/core/auth/service', () => {
const AuthService: any = vi.fn().mockImplementation(() => ({
getTokenManager: () => mockTokenManager,
hasValidToken: () => true,
getToken: () => 'mock-access-token',
hasValidToken: () => mockAuthState.hasValidToken,
getToken: () => mockAuthState.token,
authenticateWithSecret: vi.fn(),
authenticate: vi.fn().mockResolvedValue(true),
logout: mockLogout
Expand All @@ -34,6 +40,13 @@ vi.mock('../../../src/core/auth/service', () => {

vi.mock('../../../src/core/http/api-client');

// ===== TEST HELPERS =====
const createJwt = (payload: Record<string, unknown>): string => {
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${header}.${body}.signature`;
};

// ===== TEST SUITE =====
describe('UiPath Core', () => {
describe('Constructor', () => {
Expand Down Expand Up @@ -352,6 +365,113 @@ describe('UiPath Core', () => {
});
});

describe('getTokenIdentity', () => {
beforeEach(() => {
mockAuthState.hasValidToken = true;
mockAuthState.token = 'mock-access-token';
});

it('should return all 5 mapped identity fields from JWT claims', () => {
mockAuthState.token = createJwt({
email: 'jane.doe@example.com',
first_name: 'Jane',
last_name: 'Doe',
preferred_username: 'jane.doe',
name: 'Jane Doe'
});

const sdk = new UiPath({
baseUrl: TEST_CONSTANTS.BASE_URL,
orgName: TEST_CONSTANTS.ORGANIZATION_ID,
tenantName: TEST_CONSTANTS.TENANT_ID,
secret: TEST_CONSTANTS.CLIENT_SECRET
});

const identity = sdk.getTokenIdentity();

expect(identity).toEqual({
email: 'jane.doe@example.com',
firstName: 'Jane',
lastName: 'Doe',
preferredUsername: 'jane.doe',
name: 'Jane Doe'
});
});

it('should return undefined for missing claims', () => {
mockAuthState.token = createJwt({ email: 'only.email@example.com' });

const sdk = new UiPath({
baseUrl: TEST_CONSTANTS.BASE_URL,
orgName: TEST_CONSTANTS.ORGANIZATION_ID,
tenantName: TEST_CONSTANTS.TENANT_ID,
secret: TEST_CONSTANTS.CLIENT_SECRET
});

const identity = sdk.getTokenIdentity();

expect(identity.email).toBe('only.email@example.com');
expect(identity.firstName).toBeUndefined();
expect(identity.lastName).toBeUndefined();
expect(identity.preferredUsername).toBeUndefined();
expect(identity.name).toBeUndefined();
});

it('should throw AuthenticationError when user is not authenticated', () => {
mockAuthState.hasValidToken = false;

const sdk = new UiPath({
baseUrl: TEST_CONSTANTS.BASE_URL,
orgName: TEST_CONSTANTS.ORGANIZATION_ID,
tenantName: TEST_CONSTANTS.TENANT_ID,
secret: TEST_CONSTANTS.CLIENT_SECRET
});

expect(() => sdk.getTokenIdentity()).toThrow(AuthenticationError);
});

it('should throw ValidationError when token has fewer than 3 segments', () => {
Comment thread
vnaren23 marked this conversation as resolved.
mockAuthState.token = 'not-a-jwt';

const sdk = new UiPath({
baseUrl: TEST_CONSTANTS.BASE_URL,
orgName: TEST_CONSTANTS.ORGANIZATION_ID,
tenantName: TEST_CONSTANTS.TENANT_ID,
secret: TEST_CONSTANTS.CLIENT_SECRET
});

expect(() => sdk.getTokenIdentity()).toThrow(ValidationError);
Comment thread
vnaren23 marked this conversation as resolved.
});

it('should throw ValidationError when token has more than 3 segments', () => {
mockAuthState.token = 'header.payload.sig.extra';

const sdk = new UiPath({
baseUrl: TEST_CONSTANTS.BASE_URL,
orgName: TEST_CONSTANTS.ORGANIZATION_ID,
tenantName: TEST_CONSTANTS.TENANT_ID,
secret: TEST_CONSTANTS.CLIENT_SECRET
});

expect(() => sdk.getTokenIdentity()).toThrow(ValidationError);
});

it('should throw ValidationError when payload is not valid JSON', () => {
const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url');
const invalidPayload = Buffer.from('not-json-content').toString('base64url');
mockAuthState.token = `${header}.${invalidPayload}.signature`;

const sdk = new UiPath({
baseUrl: TEST_CONSTANTS.BASE_URL,
orgName: TEST_CONSTANTS.ORGANIZATION_ID,
tenantName: TEST_CONSTANTS.TENANT_ID,
secret: TEST_CONSTANTS.CLIENT_SECRET
});

expect(() => sdk.getTokenIdentity()).toThrow(ValidationError);
});
Comment thread
vnaren23 marked this conversation as resolved.
});

describe('Multiple Instance Support', () => {
it('should support creating multiple independent instances', () => {
const sdk1 = new UiPath({
Expand Down
Loading