Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,67 @@ export enum RefreshSessionFailureReason {
INVALID_SESSION_COOKIE = 'invalid_session_cookie',
NO_SESSION_COOKIE_PROVIDED = 'no_session_cookie_provided',

// API OauthErrors for refresh tokens
// Terminal API OauthErrors — the session is over and the user must
// re-authenticate.
INVALID_GRANT = 'invalid_grant',
MFA_ENROLLMENT = 'mfa_enrollment',
SSO_REQUIRED = 'sso_required',

// Transient failures — the refresh token is likely still valid and the
// request can be retried. Keep the existing session rather than signing
// the user out.
RATE_LIMIT_EXCEEDED = 'rate_limit_exceeded',
TIMEOUT = 'timeout',
SERVER_ERROR = 'server_error',
NETWORK_ERROR = 'network_error',
}

type RefreshSessionFailedResponse = {
export type TerminalRefreshSessionFailureReason =
| RefreshSessionFailureReason.INVALID_SESSION_COOKIE
| RefreshSessionFailureReason.NO_SESSION_COOKIE_PROVIDED
| RefreshSessionFailureReason.INVALID_GRANT
| RefreshSessionFailureReason.MFA_ENROLLMENT
| RefreshSessionFailureReason.SSO_REQUIRED;

export type RetryableRefreshSessionFailureReason =
| RefreshSessionFailureReason.RATE_LIMIT_EXCEEDED
| RefreshSessionFailureReason.TIMEOUT
| RefreshSessionFailureReason.SERVER_ERROR
| RefreshSessionFailureReason.NETWORK_ERROR;

/**
* A terminal refresh failure: the session is over (e.g. `invalid_grant`) and
* the user should be redirected to sign in.
*/
type RefreshSessionTerminalFailedResponse = {
authenticated: false;
reason: RefreshSessionFailureReason;
reason: TerminalRefreshSessionFailureReason;
retryable: false;
};

/**
* A transient refresh failure: the refresh token is likely still valid (e.g. a
* timeout, `5xx`, or `429`), so keep the existing session and retry later.
*/
type RefreshSessionRetryableFailedResponse = {
authenticated: false;
reason: RetryableRefreshSessionFailureReason;
retryable: true;
/**
* Seconds the server asked the client to wait before retrying, parsed from
* the `Retry-After` response header. Only present for some retryable
* failures (e.g. a `429`).
*/
retryAfter?: number;
/**
* The underlying error, exposed for logging.
*/
error?: unknown;
};

type RefreshSessionFailedResponse =
RefreshSessionTerminalFailedResponse | RefreshSessionRetryableFailedResponse;

type RefreshSessionSuccessResponse = Omit<
AuthenticateWithSessionCookieSuccessResponse,
// accessToken is available in the session object and with session
Expand Down
160 changes: 160 additions & 0 deletions src/user-management/session.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import { sealData } from '../common/crypto/seal';
import userFixture from './fixtures/user.json';
import fetch from 'jest-fetch-mock';
import { fetchOnce } from '../common/utils/test-utils';
import {
GenericServerException,
OauthException,
RateLimitExceededException,
} from '../common/exceptions';

jest.mock('jose', () => ({
...jest.requireActual('jose'),
Expand Down Expand Up @@ -175,6 +180,161 @@ describe('Session', () => {
expect(response).toEqual({
authenticated: false,
reason: 'invalid_session_cookie',
retryable: false,
});
});

describe('when the refresh fails', () => {
const accessToken =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ewogICJzdWIiOiAiMTIzNDU2Nzg5MCIsCiAgIm5hbWUiOiAiSm9obiBEb2UiLAogICJpYXQiOiAxNTE2MjM5MDIyLAogICJzaWQiOiAic2Vzc2lvbl8xMjMiLAogICJvcmdfaWQiOiAib3JnXzEyMyIsCiAgInJvbGUiOiAibWVtYmVyIiwKICAicGVybWlzc2lvbnMiOiBbInBvc3RzOmNyZWF0ZSIsICJwb3N0czpkZWxldGUiXQp9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
const cookiePassword = 'alongcookiesecretmadefortestingsessions';

async function loadSession() {
const workosNoRetries = new WorkOS(
'sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU',
{
clientId: 'client_123',
maxRetries: 0,
},
);

const sessionData = await sealData(
{
accessToken,
refreshToken: 'def456',
user: {
object: 'user',
id: 'user_01H5JQDV7R7ATEYZDEG0W5PRYS',
email: 'test01@example.com',
},
},
{ password: cookiePassword },
);

return workosNoRetries.userManagement.loadSealedSession({
sessionData,
cookiePassword,
});
}

it('returns a terminal, non-retryable result for invalid_grant', async () => {
fetchOnce(
{
error: 'invalid_grant',
error_description: 'Invalid refresh token.',
},
{ status: 400 },
);

const session = await loadSession();
const response = await session.refresh();

expect(response).toEqual({
authenticated: false,
reason: 'invalid_grant',
retryable: false,
});
});

it('returns a terminal, non-retryable result for sso_required', async () => {
fetchOnce(
{
error: 'sso_required',
error_description: 'User must authenticate via SSO.',
},
{ status: 403 },
);

const session = await loadSession();
const response = await session.refresh();

expect(response).toEqual({
authenticated: false,
reason: 'sso_required',
retryable: false,
});
});

it('returns a terminal, non-retryable result for mfa_enrollment', async () => {
fetchOnce(
{
code: 'mfa_enrollment',
message: 'User must enroll in MFA.',
},
{ status: 403 },
);

const session = await loadSession();
const response = await session.refresh();

expect(response).toEqual({
authenticated: false,
reason: 'mfa_enrollment',
retryable: false,
});
});

it('returns a retryable result with retryAfter for a 429', async () => {
fetchOnce(
{
error: 'too_many_requests',
error_description: 'Could not process refresh token. Please retry.',
},
{ status: 429, headers: { 'Retry-After': '1' } },
);

const session = await loadSession();
const response = await session.refresh();

expect(response).toEqual({
authenticated: false,
reason: 'rate_limit_exceeded',
retryable: true,
retryAfter: 1,
error: expect.any(RateLimitExceededException),
});
});

it('returns a retryable timeout result for a 408', async () => {
fetchOnce({ error: 'Request timeout' }, { status: 408 });

const session = await loadSession();
const response = await session.refresh();

expect(response).toEqual({
authenticated: false,
reason: 'timeout',
retryable: true,
error: expect.any(OauthException),
});
});

it('returns a retryable server_error result for a 5xx', async () => {
fetchOnce({ message: 'Service unavailable' }, { status: 503 });

const session = await loadSession();
const response = await session.refresh();

expect(response).toEqual({
authenticated: false,
reason: 'server_error',
retryable: true,
error: expect.any(GenericServerException),
});
});

it('returns a retryable network_error result for a network failure', async () => {
fetch.mockRejectOnce(new TypeError('Network request failed'));

const session = await loadSession();
const response = await session.refresh();

expect(response).toEqual({
authenticated: false,
reason: 'network_error',
retryable: true,
error: expect.any(Error),
});
});
});

Expand Down
Loading