Skip to content

Commit

Permalink
Merge pull request #104 from Tietokilta/feature/89-renew-session-token
Browse files Browse the repository at this point in the history
Feature/89 renew session token
  • Loading branch information
PurkkaKoodari authored Mar 7, 2024
2 parents d999a6c + 32e7f3d commit cc310ba
Show file tree
Hide file tree
Showing 11 changed files with 84 additions and 33 deletions.
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
25 changes: 22 additions & 3 deletions packages/ilmomasiina-frontend/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,33 @@
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) {
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 });
}
// Renew token asynchronously if it's expiring soon
if (Date.now() > accessToken.expiresAt - RENEW_LOGIN_THRESHOLD) {
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
33 changes: 29 additions & 4 deletions packages/ilmomasiina-frontend/src/modules/auth/actions.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { push } from 'connected-react-router';
import { toast } from 'react-toastify';

import { apiFetch } from '@tietokilta/ilmomasiina-components';
import type { AdminLoginResponse } from '@tietokilta/ilmomasiina-models';
import { ApiError, apiFetch } from '@tietokilta/ilmomasiina-components';
import { AdminLoginResponse, ErrorCode } from '@tietokilta/ilmomasiina-models';
import i18n from '../../i18n';
import appPaths from '../../paths';
import type { DispatchAction } from '../../store/types';
Expand Down 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,28 @@ export const loginExpired = () => (dispatch: DispatchAction) => {
loginToast('error', i18n.t('auth.loginExpired'), 10000);
dispatch(redirectToLogin());
};

export const renewLogin = (accessToken: string) => async (dispatch: DispatchAction) => {
try {
if (accessToken) {
const sessionResponse = await apiFetch<AdminLoginResponse>('authentication/renew', {
method: 'POST',
body: {
accessToken,
},
headers: {
Authorization: accessToken,
},
});
if (sessionResponse) {
dispatch(loginSucceeded(sessionResponse));
}
}
} catch (err) {
if (err instanceof ApiError && err.code === ErrorCode.BAD_SESSION) {
dispatch(loginExpired());
} else {
throw err;
}
}
};
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

0 comments on commit cc310ba

Please sign in to comment.