Skip to content

Commit

Permalink
fix(website): handle unavailable keycloak service to allow local dev …
Browse files Browse the repository at this point in the history
…for public pages without keycloak (#1311)

* fix(website): handle undefined session

Resolves #1163

On most pages, we know users are logged in, otherwise middleware redirects

However, middleware apparently doesn't always get a chance to set session, see #1163

* Deal with possibility of keycloak being unavailable

* Assert type

* Fix format

* feat(website): use 503 website when keycloak unavailable

* Address review comments and `astro check` warnings
  • Loading branch information
corneliusroemer committed Mar 11, 2024
1 parent 38de3a4 commit 0aa5c74
Show file tree
Hide file tree
Showing 19 changed files with 178 additions and 107 deletions.
4 changes: 2 additions & 2 deletions website/src/components/Navigation/Navigation.astro
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
---
import { SandwichMenu } from './SandwichMenu.tsx';
import { cleanOrganism } from './cleanOrganism';
import { getAuthUrl } from '../../middleware/authMiddleware';
import { navigationItems } from '../../routes';
import { getAuthUrl } from '../../utils/getAuthUrl';
const { organism, knownOrganisms } = cleanOrganism(Astro.params.organism);
const isLoggedIn = Astro.locals.session.isLoggedIn;
const isLoggedIn = Astro.locals.session?.isLoggedIn ?? false;
const loginUrl = await getAuthUrl(Astro.url.toString());
---
Expand Down
13 changes: 8 additions & 5 deletions website/src/components/User/UserPage.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,35 @@
import { ListOfGroupsOfUser } from './ListOfGroupsOfUser.tsx';
import { getRuntimeConfig } from '../../config';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getKeycloakClient, urlForKeycloakAccountPage } from '../../middleware/authMiddleware';
import { routes } from '../../routes';
import { GroupManagementClient } from '../../services/groupManagementClient';
import { KeycloakClientManager } from '../../utils/KeycloakClientManager';
import { getAccessToken } from '../../utils/getAccessToken';
import { urlForKeycloakAccountPage } from '../../utils/urlForKeycloakAccountPage';
import ErrorBox from '../common/ErrorBox.astro';
import DashiconsGroups from '~icons/dashicons/groups';
import MaterialSymbolsLightPersonOutline from '~icons/material-symbols-light/person-outline';
const session = Astro.locals.session;
const session = Astro.locals.session!;
const user = session.user!; // page only accessible if user is logged in
const username = user.username!; // all users must have a username
const name = user.name;
const accessToken = getAccessToken(Astro.locals.session)!;
const accessToken = getAccessToken(session)!;
const clientConfig = getRuntimeConfig().public;
const logoutUrl = new URL(Astro.request.url);
logoutUrl.pathname = routes.logout();
const keycloakLogoutUrl = (await getKeycloakClient()).endSessionUrl({
const keycloakClient = await KeycloakClientManager.getClient();
const keycloakLogoutUrl = keycloakClient!.endSessionUrl({
post_logout_redirect_uri: logoutUrl.href,
});
const accountPageUrl = await urlForKeycloakAccountPage(keycloakClient!);
const groupOfUsersResult = await GroupManagementClient.create().getGroupsOfUser(accessToken);
const accountPageUrl = await urlForKeycloakAccountPage();
---

<BaseLayout title='My account'>
Expand Down
2 changes: 1 addition & 1 deletion website/src/components/common/NeedToLogin.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
import { getAuthUrl } from '../../middleware/authMiddleware';
import { getAuthUrl } from '../../utils/getAuthUrl';
import IcOutlineLogin from '~icons/ic/outline-login';
const loginUrl = await getAuthUrl(Astro.url.toString());
Expand Down
2 changes: 1 addition & 1 deletion website/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ type Session = {

declare namespace App {
interface Locals {
session: Session;
session?: Session;
}
}
135 changes: 50 additions & 85 deletions website/src/middleware/authMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import { defineMiddleware } from 'astro/middleware';
import jsonwebtoken from 'jsonwebtoken';
import JwksRsa from 'jwks-rsa';
import { err, ok, ResultAsync } from 'neverthrow';
import { type BaseClient, Issuer, type TokenSet } from 'openid-client';
import { type BaseClient, type TokenSet } from 'openid-client';

import { getConfiguredOrganisms, getRuntimeConfig } from '../config.ts';
import { getConfiguredOrganisms } from '../config.ts';
import { getInstanceLogger } from '../logger.ts';
import { routes } from '../routes';
import { KeycloakClientManager } from '../utils/KeycloakClientManager.ts';
import { getAuthUrl } from '../utils/getAuthUrl.ts';
import { shouldMiddlewareEnforceLogin } from '../utils/shouldMiddlewareEnforceLogin.ts';

export const ACCESS_TOKEN_COOKIE = 'access_token';
Expand All @@ -19,52 +20,13 @@ enum TokenVerificationError {
INVALID_TOKEN,
}

export const clientMetadata = {
client_id: 'test-cli', // TODO: #1100 Replace with actual client id
response_types: ['code', 'id_token'],
client_secret: 'someSecret',
public: true,
};

export const realmPath = '/realms/loculus';

let _keycloakClient: BaseClient | undefined;

const logger = getInstanceLogger('LoginMiddleware');

export async function getKeycloakClient() {
if (_keycloakClient === undefined) {
const originForClient = getRuntimeConfig().serverSide.keycloakUrl;

const issuerUrl = `${originForClient}${realmPath}`;

logger.info(`Getting keycloak client for issuer url: ${issuerUrl}`);
const keycloakIssuer = await Issuer.discover(issuerUrl);

_keycloakClient = new keycloakIssuer.Client(clientMetadata);
}

return _keycloakClient;
}

export const getAuthUrl = async (redirectUrl: string) => {
const logout = routes.logout();
if (redirectUrl.endsWith(logout)) {
redirectUrl = redirectUrl.replace(logout, routes.userOverviewPage());
}
const authUrl = (await getKeycloakClient()).authorizationUrl({
redirect_uri: redirectUrl,
scope: 'openid',
response_type: 'code',
});
return authUrl;
};

async function getValidTokenAndUserInfoFromCookie(context: APIContext) {
async function getValidTokenAndUserInfoFromCookie(context: APIContext, client: BaseClient) {
logger.debug(`Trying to get token and user info from cookie`);
const token = await getTokenFromCookie(context);
const token = await getTokenFromCookie(context, client);
if (token !== undefined) {
const userInfo = await getUserInfo(token);
const userInfo = await getUserInfo(token, client);

if (userInfo.isErr()) {
logger.debug(`Cookie token found but could not get user info`);
Expand All @@ -80,11 +42,11 @@ async function getValidTokenAndUserInfoFromCookie(context: APIContext) {
return undefined;
}

async function getValidTokenAndUserInfoFromParams(context: APIContext) {
async function getValidTokenAndUserInfoFromParams(context: APIContext, client: BaseClient) {
logger.debug(`Trying to get token and user info from params`);
const token = await getTokenFromParams(context);
const token = await getTokenFromParams(context, client);
if (token !== undefined) {
const userInfo = await getUserInfo(token);
const userInfo = await getUserInfo(token, client);

if (userInfo.isErr()) {
logger.debug(`Token found in params but could not get user info`);
Expand All @@ -100,17 +62,28 @@ async function getValidTokenAndUserInfoFromParams(context: APIContext) {
}

export const authMiddleware = defineMiddleware(async (context, next) => {
let { token, userInfo } = (await getValidTokenAndUserInfoFromCookie(context)) ?? {};
if (token === undefined) {
const paramResult = await getValidTokenAndUserInfoFromParams(context);
token = paramResult?.token;
userInfo = paramResult?.userInfo;

if (token !== undefined) {
logger.debug(`Token found in params, setting cookie`);
setCookie(context, token);
return createRedirectWithModifiableHeaders(removeTokenCodeFromSearchParams(context.url));
let token: TokenCookie | undefined;
let userInfo;

const client = await KeycloakClientManager.getClient();
if (client !== undefined) {
// Only run this when keycloak up
const cookieResult = await getValidTokenAndUserInfoFromCookie(context, client);
token = cookieResult?.token;
userInfo = cookieResult?.userInfo;
if (token === undefined) {
const paramResult = await getValidTokenAndUserInfoFromParams(context, client);
token = paramResult?.token;
userInfo = paramResult?.userInfo;

if (token !== undefined) {
logger.debug(`Token found in params, setting cookie`);
setCookie(context, token);
return createRedirectWithModifiableHeaders(removeTokenCodeFromSearchParams(context.url));
}
}
} else {
logger.warn(`Keycloak client not available, pretending user logged out`);
}

const enforceLogin = shouldMiddlewareEnforceLogin(
Expand All @@ -119,6 +92,10 @@ export const authMiddleware = defineMiddleware(async (context, next) => {
);

if (enforceLogin && (userInfo === undefined || userInfo.isErr())) {
if (client === undefined) {
logger.error(`Keycloak client not available, cannot redirect to auth`);
return context.redirect('/503?service=Authentication');
}
return redirectToAuth(context);
}

Expand Down Expand Up @@ -154,7 +131,7 @@ export const authMiddleware = defineMiddleware(async (context, next) => {
return next();
});

async function getTokenFromCookie(context: APIContext) {
async function getTokenFromCookie(context: APIContext, client: BaseClient) {
const accessToken = context.cookies.get(ACCESS_TOKEN_COOKIE)?.value;
const refreshToken = context.cookies.get(REFRESH_TOKEN_COOKIE)?.value;

Expand All @@ -166,10 +143,10 @@ async function getTokenFromCookie(context: APIContext) {
refreshToken,
};

const verifiedTokenResult = await verifyToken(accessToken);
const verifiedTokenResult = await verifyToken(accessToken, client);
if (verifiedTokenResult.isErr() && verifiedTokenResult.error.type === TokenVerificationError.EXPIRED) {
logger.debug(`Token expired, trying to refresh`);
return refreshTokenViaKeycloak(tokenCookie);
return refreshTokenViaKeycloak(tokenCookie, client);
}
if (verifiedTokenResult.isErr()) {
logger.info(`Error verifying token: ${verifiedTokenResult.error.message}`);
Expand All @@ -180,7 +157,7 @@ async function getTokenFromCookie(context: APIContext) {
return tokenCookie;
}

async function verifyToken(accessToken: string) {
async function verifyToken(accessToken: string, client: BaseClient) {
logger.debug(`Verifying token`);
const tokenHeader = jsonwebtoken.decode(accessToken, { complete: true })?.header;
const kid = tokenHeader?.kid;
Expand All @@ -191,19 +168,15 @@ async function verifyToken(accessToken: string) {
});
}

const keycloakClient = await getKeycloakClient();

if (keycloakClient.issuer.metadata.jwks_uri === undefined) {
if (client.issuer.metadata.jwks_uri === undefined) {
return err({
type: TokenVerificationError.REQUEST_ERROR,
message: `Keycloak client does not contain jwks_uri: ${JSON.stringify(
keycloakClient.issuer.metadata.jwks_uri,
)}`,
message: `Keycloak client does not contain jwks_uri: ${JSON.stringify(client.issuer.metadata.jwks_uri)}`,
});
}

const jwksClient = new JwksRsa.JwksClient({
jwksUri: keycloakClient.issuer.metadata.jwks_uri,
jwksUri: client.issuer.metadata.jwks_uri,
});

try {
Expand Down Expand Up @@ -232,16 +205,14 @@ async function verifyToken(accessToken: string) {
}
}

async function getUserInfo(token: TokenCookie) {
return ResultAsync.fromPromise((await getKeycloakClient()).userinfo(token.accessToken), (error) => {
async function getUserInfo(token: TokenCookie, client: BaseClient) {
return ResultAsync.fromPromise(client.userinfo(token.accessToken), (error) => {
logger.debug(`Error getting user info: ${error}`);
return error;
});
}

async function getTokenFromParams(context: APIContext): Promise<TokenCookie | undefined> {
const client = await getKeycloakClient();

async function getTokenFromParams(context: APIContext, client: BaseClient): Promise<TokenCookie | undefined> {
const params = client.callbackParams(context.url.toString());
logger.debug(`Keycloak callback params: ${JSON.stringify(params)}`);
if (params.code !== undefined) {
Expand All @@ -260,7 +231,7 @@ async function getTokenFromParams(context: APIContext): Promise<TokenCookie | un
return undefined;
}

export function setCookie(context: APIContext, token: TokenCookie) {
function setCookie(context: APIContext, token: TokenCookie) {
logger.debug(`Setting token cookie`);
context.cookies.set(ACCESS_TOKEN_COOKIE, token.accessToken, {
httpOnly: true,
Expand All @@ -287,9 +258,10 @@ function deleteCookie(context: APIContext) {
}

// https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Basic_concepts#guard
// URL must be absolute, otherwise throws TypeError
const createRedirectWithModifiableHeaders = (url: string) => {
const redirect = Response.redirect(url);
logger.debug(`Redirecting to ${url}`);
const redirect = Response.redirect(url);
return new Response(null, { status: redirect.status, headers: redirect.headers });
};

Expand All @@ -314,8 +286,8 @@ function removeTokenCodeFromSearchParams(url: URL): string {
return newUrl.toString();
}

async function refreshTokenViaKeycloak(token: TokenCookie): Promise<TokenCookie | undefined> {
const refreshedTokenSet = await (await getKeycloakClient()).refresh(token.refreshToken).catch(() => {
async function refreshTokenViaKeycloak(token: TokenCookie, client: BaseClient): Promise<TokenCookie | undefined> {
const refreshedTokenSet = await client.refresh(token.refreshToken).catch(() => {
logger.info(`Failed to refresh token`);
return undefined;
});
Expand All @@ -336,10 +308,3 @@ function extractTokenCookieFromTokenSet(tokenSet: TokenSet | undefined): TokenCo
refreshToken,
};
}

export async function urlForKeycloakAccountPage() {
const client = await getKeycloakClient();
const endsessionUrl = client.endSessionUrl();
const host = new URL(endsessionUrl).host;
return `https://${host}${realmPath}/account`;
}
25 changes: 25 additions & 0 deletions website/src/pages/503.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
import { capitalCase } from 'change-case';
import BaseLayout from '../layouts/BaseLayout.astro';
const allowedServiceNames = ['authentication'];
const serviceParam = Astro.url.searchParams.get('service');
let service = 'Internal';
let bodyService = 'internal service you are trying to access';
if (serviceParam !== null && serviceParam !== '' && allowedServiceNames.includes(serviceParam.toLowerCase())) {
service = capitalCase(serviceParam);
bodyService = `${service.toLowerCase()} service`;
}
---

<BaseLayout title={`${service} Service Unavailable`}>
<div class='bc'>
<h1 class='title'>{`${service} Service Unavailable`}</h1>
<p>
The {bodyService} is currently unavailable. Please check back later.
</p>
</div>
</BaseLayout>
5 changes: 3 additions & 2 deletions website/src/pages/datasets/[datasetId].astro
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ import type { Dataset } from '../../types/datasetCitation';
import { getAccessToken } from '../../utils/getAccessToken';
const clientConfig = getRuntimeConfig().public;
const accessToken = getAccessToken(Astro.locals.session)!;
const session = Astro.locals.session;
const accessToken = getAccessToken(session)!;
const { datasetId = '' } = Astro.params;
const version = Astro.url.searchParams.get('version')! || '1';
const username = Astro.locals.session.user?.username;
const username = session?.user?.username;
const datasetClient = DatasetCitationClient.create();
Expand Down
6 changes: 3 additions & 3 deletions website/src/pages/datasets/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import { DatasetCitationClient } from '../../services/datasetCitationClient.ts';
import { getAccessToken } from '../../utils/getAccessToken';
const clientConfig = getRuntimeConfig().public;
const accessToken = getAccessToken(Astro.locals.session)!;
const username = Astro.locals.session.user!.username!;
const session = Astro.locals.session;
const session = Astro.locals.session!;
const accessToken = getAccessToken(session)!;
const username = session.user!.username!;
const datasetClient = DatasetCitationClient.create();
Expand Down
5 changes: 3 additions & 2 deletions website/src/pages/group/[groupName]/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import BaseLayout from '../../../layouts/BaseLayout.astro';
import { GroupManagementClient } from '../../../services/groupManagementClient';
import { getAccessToken } from '../../../utils/getAccessToken';
const accessToken = getAccessToken(Astro.locals.session)!;
const username = Astro.locals.session.user!.username!;
const session = Astro.locals.session!;
const accessToken = getAccessToken(session)!;
const username = session.user!.username!;
const groupName = Astro.params.groupName!;
const clientConfig = getRuntimeConfig().public;
Expand Down
Loading

0 comments on commit 0aa5c74

Please sign in to comment.