Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
16 changes: 8 additions & 8 deletions app/controllers/api/internal/sessions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ class SessionsController < ApplicationController
respond_to :json

def show
render json: { live: live?, timeout: timeout }
render json: status_response
end

def update
analytics.session_kept_alive if live?
update_last_request_at
render json: { live: live?, timeout: timeout }
render json: status_response
end

def destroy
Expand All @@ -29,21 +29,21 @@ def destroy

private

def status_response
{ live: live?, timeout: live?.presence && timeout }
end

def skip_devise_hooks
request.env['devise.skip_timeout'] = true
request.env['devise.skip_trackable'] = true
end

def live?
timeout.future?
timeout.present? && timeout.future?
end

def timeout
if last_request_at.present?
Time.zone.at(last_request_at + User.timeout_in)
else
Time.current
end
Time.zone.at(last_request_at + User.timeout_in) if last_request_at.present?
end

def last_request_at
Expand Down
73 changes: 48 additions & 25 deletions app/javascript/packages/session/requests.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,49 +7,72 @@ import {
requestSessionStatus,
extendSession,
} from './requests';
import type { SessionStatusResponse } from './requests';
import type { SessionLiveStatusResponse, SessionTimedOutStatusResponse } from './requests';

describe('requestSessionStatus', () => {
let isLive: boolean;
let timeout: string;

let server: SetupServer;
before(() => {
server = setupServer(
rest.get<{}, {}, SessionStatusResponse>(STATUS_API_ENDPOINT, (_req, res, ctx) =>
res(ctx.json({ live: isLive, timeout })),
),
);
server.listen();
});

after(() => {
server.close();
});

context('session inactive', () => {
beforeEach(() => {
isLive = false;
timeout = new Date().toISOString();
before(() => {
server = setupServer(
rest.get<{}, {}, SessionTimedOutStatusResponse>(STATUS_API_ENDPOINT, (_req, res, ctx) =>
res(ctx.json({ live: false, timeout: null })),
),
);
server.listen();
});

after(() => {
server.close();
});

it('resolves to the status', async () => {
const result = await requestSessionStatus();

expect(result).to.deep.equal({ isLive: false, timeout });
expect(result).to.deep.equal({ isLive: false });
});
});

context('session active', () => {
beforeEach(() => {
isLive = true;
let timeout: string;

before(() => {
timeout = new Date(Date.now() + 1000).toISOString();
server = setupServer(
rest.get<{}, {}, SessionLiveStatusResponse>(STATUS_API_ENDPOINT, (_req, res, ctx) =>
res(ctx.json({ live: true, timeout })),
),
);
server.listen();
});

after(() => {
server.close();
});

it('resolves to the status', async () => {
const result = await requestSessionStatus();

expect(result).to.deep.equal({ isLive: true, timeout: new Date(timeout) });
});
});

context('server responds with 401', () => {
before(() => {
server = setupServer(
rest.get<{}, {}>(STATUS_API_ENDPOINT, (_req, res, ctx) => res(ctx.status(401))),
);
server.listen();
});

after(() => {
server.close();
});

it('resolves to the status', async () => {
const result = await requestSessionStatus();

expect(result).to.deep.equal({ isLive: true, timeout });
expect(result).to.deep.equal({ isLive: false });
});
});
});
Expand All @@ -60,7 +83,7 @@ describe('extendSession', () => {
let server: SetupServer;
before(() => {
server = setupServer(
rest.post<{}, {}, SessionStatusResponse>(KEEP_ALIVE_API_ENDPOINT, (_req, res, ctx) =>
rest.post<{}, {}, SessionLiveStatusResponse>(KEEP_ALIVE_API_ENDPOINT, (_req, res, ctx) =>
res(ctx.json({ live: true, timeout })),
),
);
Expand All @@ -74,6 +97,6 @@ describe('extendSession', () => {
it('resolves to the status', async () => {
const result = await extendSession();

expect(result).to.deep.equal({ isLive: true, timeout });
expect(result).to.deep.equal({ isLive: true, timeout: new Date(timeout) });
});
});
56 changes: 47 additions & 9 deletions app/javascript/packages/session/requests.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,82 @@
import { request } from '@18f/identity-request';

export interface SessionStatusResponse {
export interface SessionLiveStatusResponse {
/**
* Whether the session is still active.
*/
live: boolean;
live: true;

/**
* ISO8601-formatted date string for session timeout.
*/
timeout: string;
}

export interface SessionStatus {
export interface SessionTimedOutStatusResponse {
/**
* Whether the session is still active.
*/
isLive: boolean;
live: false;

/**
* ISO8601-formatted date string for session timeout.
*/
timeout: string;
timeout: null;
}

type SessionStatusResponse = SessionLiveStatusResponse | SessionTimedOutStatusResponse;

interface SessionLiveStatus {
/**
* Whether the session is still active.
*/
isLive: true;

/**
* ISO8601-formatted date string for session timeout.
*/
timeout: Date;
}

interface SessionTimedOutStatus {
/**
* Whether the session is still active.
*/
isLive: false;

/**
* ISO8601-formatted date string for session timeout.
*/
timeout?: undefined;
}

export type SessionStatus = SessionLiveStatus | SessionTimedOutStatus;

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.

how come have both SessionStatus and SessionStatusResponse? I feel like having the union type is useful for the actual TS code, since the code is already mapping responses from the server explicitly, having the union type for SessionStatusResponse seems like it doesn't give us much?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It was probably useful when I was trying different implementations to get the type narrowing to cooperate with different generic forms. I suspect I left it mostly as a nice symmetry between the "input" and "output" types. Ultimately though, yeah, it's unused, and if anything the overloaded function generic using it would be clearer by just spelling out the union of overloaded forms explicitly.

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.

yeah if I was writing this I'd probably collapse the "raw" server types into a singular one, and use the union type for the one passed around in TS code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hm, I think I may have initially misunderstood the suggestion. But after digging in, both interpretations lead me to think that maybe it's worth keeping as-is:

  • If the idea is to remove SessionStatusResponse as a union of SessionLiveStatusResponse | SessionTimedOutStatusResponse, while keeping the latter types, this makes sense for the overloaded method signature, but makes the request generic parameter below a little less expressive.
  • If the idea is to flatten SessionStatusResponse as the interface and update property types to reflect all forms (live: boolean; timeout: string | null), this makes TypeScript unable to do type narrowing in the logic of the mapping function, specifically on calling the Date constructor with a possibly-null timeout (screenshot).

As a compromise, I'd suggest keeping the union type for the request calls, and updating the overloaded signature to re-reference the individual response types, to reflect the overloaded forms. See 0b0efb5.

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.

yeah I had intended the "flatten SessionStatusResponse as the interface and update property types to reflect all forms" approach, but the screenshot makes a good case for keeping. Thanks for trying!


export const STATUS_API_ENDPOINT = '/active';
export const KEEP_ALIVE_API_ENDPOINT = '/sessions/keepalive';

const mapSessionStatusResponse = ({ live, timeout }: SessionStatusResponse): SessionStatus => ({
isLive: live,
function mapSessionStatusResponse<R extends SessionLiveStatusResponse>(
response: R,
): SessionLiveStatus;
function mapSessionStatusResponse<R extends SessionTimedOutStatusResponse>(
response: R,
): SessionTimedOutStatus;
function mapSessionStatusResponse<R extends SessionStatusResponse>({
live,
timeout,
});
}: R): SessionLiveStatus | SessionTimedOutStatus {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The types here were giving me trouble where I was hoping TypeScript would be a bit smarter about resolving between true & false to narrow the type, but it wasn't working the way I'd hoped.

return live ? { isLive: true, timeout: new Date(timeout) } : { isLive: false };
}

/**
* Request the current session status. Returns a promise resolving to the current session status.
*
* @return A promise resolving to the current session status
*/
export const requestSessionStatus = (): Promise<SessionStatus> =>
request<SessionStatusResponse>(STATUS_API_ENDPOINT).then(mapSessionStatusResponse);
request<SessionStatusResponse>(STATUS_API_ENDPOINT)
.catch(() => ({ live: false, timeout: null }))
Comment thread
aduth marked this conversation as resolved.
Outdated
.then(mapSessionStatusResponse);

/**
* Request that the current session be kept alive. Returns a promise resolving to the updated
Expand Down
8 changes: 4 additions & 4 deletions app/javascript/packs/session-timeout-ping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,20 +47,20 @@ function handleTimeout(redirectURL: string) {
forceRedirect(redirectURL);
}

function success(data: SessionStatus) {
if (!data.isLive) {
function success({ isLive, timeout }: SessionStatus) {
if (!isLive) {
if (timeoutUrl) {
handleTimeout(timeoutUrl);
}
return;
}

const timeRemaining = new Date(data.timeout).valueOf() - Date.now();
const timeRemaining = timeout.valueOf() - Date.now();
const showWarning = timeRemaining < warning;
if (showWarning) {
modal.show();
countdownEls.forEach((countdownEl) => {
countdownEl.expiration = new Date(data.timeout);
countdownEl.expiration = timeout;
countdownEl.start();
});
}
Expand Down
7 changes: 2 additions & 5 deletions spec/controllers/api/internal/sessions_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
subject(:response) { JSON.parse(get(:show).body, symbolize_names: true) }

it 'responds with live and timeout properties' do
expect(response).to eq(live: false, timeout: Time.zone.now.as_json)
expect(response).to eq(live: false, timeout: nil)
end

context 'signed in' do
Expand Down Expand Up @@ -45,10 +45,7 @@
let(:delay) { User.timeout_in + 1.second }

it 'responds with live and timeout properties' do
expect(response).to eq(
live: false,
timeout: (User.timeout_in - delay).from_now.as_json,
)
expect(response).to eq(live: false, timeout: nil)
end
end
end
Expand Down
22 changes: 17 additions & 5 deletions spec/features/saml/ial1_sso_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,22 +70,34 @@
)
end

it 'after session timeout, signing in takes user back to SP' do
it 'after session timeout, signing in takes user back to SP', :js, allow_browser_log: true do
allow(IdentityConfig.store).to receive(:session_check_delay).and_return(0)

user = create(:user, :fully_registered)
request_url = saml_authn_request_url

visit request_url
sp_request_id = ServiceProviderRequestProxy.last.uuid
fill_in_credentials_and_submit(user.email, user.password)

Warden.on_next_request do |proxy|
proxy.env['devise.skip_trackable'] = true
session = proxy.env['rack.session']
session['warden.user.user.session']['last_request_at'] = User.timeout_in.ago.to_i - 1
Comment thread
aduth marked this conversation as resolved.
Outdated
end

visit timeout_path
expect(current_url).to eq root_url(request_id: sp_request_id)
expect(page).to have_current_path(new_user_session_path(request_id: sp_request_id), wait: 5)
allow(IdentityConfig.store).to receive(:session_check_delay).and_call_original

fill_in_credentials_and_submit(user.email, user.password)
fill_in_code_with_last_phone_otp
click_submit_default_twice
click_submit_default

# SAML does internal redirect using JavaScript prior to showing consent screen
expect(page).to have_current_path(sign_up_completed_path, wait: 5)
click_agree_and_continue

expect(current_url).to eq complete_saml_url
expect(page).to have_current_path(test_saml_decode_assertion_path)
end
end

Expand Down