Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/89 renew session token #104

Merged
merged 8 commits into from
Mar 7, 2024
Merged
Show file tree
Hide file tree
Changes from 7 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 @@ -34,7 +34,7 @@ export function adminLogin(session: AdminAuthSession) {

export function renewAdminToken(session: AdminAuthSession) {
return async (
request: FastifyRequest<{ Body: AdminLoginBody }>,
request: FastifyRequest,
reply: FastifyReply,
): Promise<AdminLoginResponse | void> => {
// Verify existing token
Expand Down
15 changes: 13 additions & 2 deletions packages/ilmomasiina-backend/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import deleteUser from './admin/users/deleteUser';
import inviteUser from './admin/users/inviteUser';
import listUsers from './admin/users/listUsers';
import resetPassword from './admin/users/resetPassword';
import { adminLogin, requireAdmin } from './authentication/adminLogin';
import { adminLogin, renewAdminToken, requireAdmin } from './authentication/adminLogin';
import { getEventDetailsForAdmin, getEventDetailsForUser } from './events/getEventDetails';
import { getEventsListForAdmin, getEventsListForUser } from './events/getEventsList';
import { sendICalFeed } from './ical';
Expand Down Expand Up @@ -323,7 +323,18 @@ async function setupPublicRoutes(
adminLogin(opts.adminSession),
);

// TODO: Add an API endpoint for session token renewal as variant of adminLoginSchema
server.post(
'/authentication/renew',
{
schema: {
response: {
...errorResponses,
201: schema.adminLoginResponse,
},
},
},
renewAdminToken(opts.adminSession),
);

// Public routes for events

Expand Down
12 changes: 4 additions & 8 deletions packages/ilmomasiina-components/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ export interface FetchOptions {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: any;
headers?: Record<string, string>;
accessToken?: string;
signal?: AbortSignal;
}

Expand Down Expand Up @@ -40,15 +39,12 @@ export function configureApi(url: string) {
apiUrl = url;
}

export default async function apiFetch(uri: string, {
method = 'GET', body, headers, accessToken, signal,
export default async function apiFetch<T = unknown>(uri: string, {
method = 'GET', body, headers, signal,
}: FetchOptions = {}) {
const allHeaders = {
...headers || {},
};
if (accessToken) {
allHeaders.Authorization = accessToken;
}
if (body !== undefined) {
allHeaders['Content-Type'] = 'application/json; charset=utf-8';
}
Expand All @@ -68,10 +64,10 @@ export default async function apiFetch(uri: string, {
}
// 204 No Content
if (response.status === 204) {
return null;
return null as T;
}
// just in case, convert JSON parse errors for 2xx responses to ApiError
return response.json().catch((err) => {
throw new ApiError(0, err);
});
}) as Promise<T>;
}
1 change: 0 additions & 1 deletion packages/ilmomasiina-components/src/contexts/auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { createContext } from 'react';

export interface AuthState {
accessToken?: string;
loggedIn: boolean;
}

Expand Down
19 changes: 16 additions & 3 deletions packages/ilmomasiina-frontend/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
import { ApiError, apiFetch, FetchOptions } from '@tietokilta/ilmomasiina-components';
import { ErrorCode } from '@tietokilta/ilmomasiina-models';
import { loginExpired } from './modules/auth/actions';
import { loginExpired, renewLogin } from './modules/auth/actions';
import { AccessToken } from './modules/auth/types';
import type { DispatchAction } from './store/types';

interface AdminApiFetchOptions extends FetchOptions {
accessToken?: AccessToken;
}
const RENEW_LOGIN_THRESHOLD = 5 * 60 * 1000;
/** Wrapper for apiFetch that checks for Unauthenticated responses and dispatches a loginExpired
* action if necessary.
*/
export default async function adminApiFetch(uri: string, opts: FetchOptions, dispatch: DispatchAction) {
// eslint-disable-next-line max-len
export default async function adminApiFetch<T = unknown>(uri: string, opts: AdminApiFetchOptions, dispatch: DispatchAction) {
try {
return await apiFetch(uri, opts);
const { accessToken } = opts;
if (!accessToken) {
throw new ApiError(401, { isUnauthenticated: true });
}
if (!(accessToken.expiresAt < Date.now() - RENEW_LOGIN_THRESHOLD)) {
await dispatch(renewLogin(accessToken.token));
}
return await apiFetch<T>(uri, { ...opts, headers: { ...opts.headers, Authorization: accessToken.token } });
} catch (err) {
if (err instanceof ApiError && err.code === ErrorCode.BAD_SESSION) {
dispatch(loginExpired());
Expand Down
4 changes: 2 additions & 2 deletions packages/ilmomasiina-frontend/src/containers/requireAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ export default function requireAuth<P extends {}>(WrappedComponent: ComponentTyp
const RequireAuth = (props: P) => {
const dispatch = useTypedDispatch();

const { accessToken, accessTokenExpires } = useTypedSelector(
const { accessToken } = useTypedSelector(
(state) => state.auth,
);

const expired = accessTokenExpires && new Date(accessTokenExpires) < new Date();
const expired = accessToken && accessToken.expiresAt < Date.now();
const needLogin = expired || !accessToken;

useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ export type AdminEventsActions =
| ReturnType<typeof resetState>;

export const getAdminEvents = () => async (dispatch: DispatchAction, getState: GetState) => {
const { accessToken } = getState().auth;
try {
const { accessToken } = getState().auth;
const response = await adminApiFetch('admin/events', { accessToken }, dispatch);
dispatch(eventsLoaded(response as AdminEventListResponse));
} catch (e) {
Expand Down
24 changes: 22 additions & 2 deletions packages/ilmomasiina-frontend/src/modules/auth/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,13 @@ const loginToast = (type: 'success' | 'error', text: string, autoClose: number)
};

export const login = (email: string, password: string) => async (dispatch: DispatchAction) => {
const sessionResponse = await apiFetch('authentication', {
const sessionResponse = await apiFetch<AdminLoginResponse>('authentication', {
method: 'POST',
body: {
email,
password,
},
}) as AdminLoginResponse;
});
dispatch(loginSucceeded(sessionResponse));
dispatch(push(appPaths.adminEventsList));
loginToast('success', i18n.t('auth.loginSuccess'), 2000);
Expand Down Expand Up @@ -78,3 +78,23 @@ export const loginExpired = () => (dispatch: DispatchAction) => {
loginToast('error', i18n.t('auth.loginExpired'), 10000);
dispatch(redirectToLogin());
};

export const renewLogin = (accessToken: string) => async (dispatch: DispatchAction) => {
if (accessToken) {
const sessionResponse = await apiFetch<AdminLoginResponse>('authentication/renew', {
method: 'POST',
body: {
accessToken,
},
headers: {
Authorization: accessToken,
},
});
if (sessionResponse) {
dispatch(loginSucceeded(sessionResponse));
return true;
}
}
dispatch(loginExpired());
return false;
};
15 changes: 7 additions & 8 deletions packages/ilmomasiina-frontend/src/modules/auth/reducer.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,26 @@
import moment, { Moment } from 'moment';

import { LOGIN_SUCCEEDED, RESET } from './actionTypes';
import type { AuthActions, AuthState } from './types';

const initialState: AuthState = {
accessToken: undefined,
accessTokenExpires: undefined,
loggedIn: false,
};

function getTokenExpiry(jwt: string): Moment {
function getTokenExpiry(jwt: string): number {
const parts = jwt.split('.');

try {
const payload = JSON.parse(window.atob(parts[1]));

if (payload.exp) {
return moment.unix(payload.exp);
return payload.exp * 1000;
}
} catch {
// eslint-disable-next-line no-console
console.error('Invalid jwt token received!');
}

return moment();
return 0;
}

export default function reducer(
Expand All @@ -35,8 +32,10 @@ export default function reducer(
return initialState;
case LOGIN_SUCCEEDED:
return {
accessToken: action.payload.accessToken,
accessTokenExpires: getTokenExpiry(action.payload.accessToken).toISOString(),
accessToken: {
token: action.payload.accessToken,
expiresAt: getTokenExpiry(action.payload.accessToken),
},
loggedIn: true,
};
default:
Expand Down
7 changes: 5 additions & 2 deletions packages/ilmomasiina-frontend/src/modules/auth/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
export interface AccessToken {
token: string;
expiresAt: number; // Unix timestamp
}
export interface AuthState {
accessToken?: string;
accessTokenExpires?: string;
accessToken?: AccessToken;
loggedIn: boolean;
}

Expand Down
1 change: 0 additions & 1 deletion packages/ilmomasiina-models/src/schema/login/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ export const adminLoginBody = Type.Object({
description: 'Plaintext password.',
}),
});

/** Response schema for a successful login. */
export const adminLoginResponse = Type.Object({
accessToken: Type.String({
Expand Down