Skip to content

Commit

Permalink
Fix refresh of ID tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
floscher committed May 16, 2023
1 parent 0f6e5aa commit 1c27a41
Show file tree
Hide file tree
Showing 12 changed files with 159 additions and 65 deletions.
4 changes: 2 additions & 2 deletions client/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,9 @@ watch(route, async (value) => {
}
});
onMounted(async () => {
onMounted(() => {
// listen for token-changed event to gracefully handle login/logout
window.addEventListener("token-changed", async (event) => {
window.addEventListener("token-changed", (event) => {
if (!loggedInUser.value) {
setLoginUserAndPermissions();
}
Expand Down
16 changes: 10 additions & 6 deletions client/src/util/api-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { loadIdToken } from "@client/util/storage.js";
import { loadIdToken, updateIdToken } from "@client/util/storage.js";
import type {
AiSummaryData,
DataUrl,
Expand All @@ -9,7 +9,7 @@ import type {
OAuthAccount,
SupportedImageMimeType,
} from "@fumix/fu-blog-common";
import { imageBytesToDataUrl } from "@fumix/fu-blog-common";
import { HttpHeader, imageBytesToDataUrl } from "@fumix/fu-blog-common";

export type ApiUrl = `/api/${string}`;

Expand All @@ -28,9 +28,9 @@ async function callServer<
const token = authenticated ? loadIdToken() : undefined;
const headers: HeadersInit = { Accept: responseType };
if (token) {
headers["X-OAuth-Type"] = token.type;
headers["X-OAuth-Issuer"] = token.issuer;
headers["Authorization"] = `Bearer ${token.id_token}`;
headers[HttpHeader.Request.OAUTH_TYPE] = token.type;
headers[HttpHeader.Request.OAUTH_ISSUER] = token.issuer;
headers[HttpHeader.Request.AUTHORIZATION] = `Bearer ${token.id_token}`;
}
if (!(payload instanceof ApiRequestJsonPayloadWithFiles)) {
headers["Content-Type"] = contentType;
Expand All @@ -39,11 +39,15 @@ async function callServer<
return fetch(url, {
method,
headers,
body: payload === null || payload instanceof ApiRequestJsonPayloadWithFiles ? toFormData(payload) : JSON.stringify(payload),
body: payload === null || payload instanceof ApiRequestJsonPayloadWithFiles ? toFormData(payload) : JSON.stringify(payload.json),
}).then(async (response) => {
if (!response.ok) {
throw new Error("Error response: " + response.status + " " + response.statusText);
}
const refreshedIdToken = response.headers.get(HttpHeader.Response.OAUTH_REFRESHED_ID_TOKEN);
if (refreshedIdToken) {
updateIdToken(refreshedIdToken);
}
if (responseType === "application/json") {
return response
.json()
Expand Down
21 changes: 19 additions & 2 deletions client/src/util/storage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { SavedOAuthToken, UserTheme } from "@fumix/fu-blog-common";
import { isOAuthType } from "@fumix/fu-blog-common";
import type { JsonWebToken, SavedOAuthToken, UserTheme } from "@fumix/fu-blog-common";
import { base64UrlToBuffer, isOAuthType } from "@fumix/fu-blog-common";

type OAuthState = { key: string; redirect_uri?: string };
const idTokenKey = "id_token";
Expand All @@ -19,6 +19,7 @@ export function loadOauthStateByKey(key: string | undefined | null): OAuthState

export function saveIdToken(token: SavedOAuthToken | null): void {
if (token) {
logIdTokenValidity(token.id_token);
saveToStorageAsString(window.localStorage, idTokenKey, token.id_token);
saveToStorageAsString(window.localStorage, oauthTypeKey, token.type);
saveToStorageAsString(window.localStorage, oauthIssuerKey, token.issuer);
Expand All @@ -30,6 +31,22 @@ export function saveIdToken(token: SavedOAuthToken | null): void {
window.dispatchEvent(new CustomEvent("token-changed", { detail: token }));
}

export function updateIdToken(idToken: JsonWebToken | null): void {
if (idToken) {
logIdTokenValidity(idToken);
saveToStorageAsString(window.localStorage, idTokenKey, idToken);
} else {
removeKeyFromStorage(window.localStorage, idTokenKey);
}
}

function logIdTokenValidity(idToken: JsonWebToken): void {
const payload = idToken.split(".", 3)[1];
if (payload) {
console.debug("New ID token saved, valid until ", new Date(1000 * JSON.parse(base64UrlToBuffer(payload).toString("utf-8"))["exp"]));
}
}

export function loadIdToken(): SavedOAuthToken | undefined {
const id_token = loadFromStorageAsStringOrUndefined(window.localStorage, idTokenKey);
const type = loadFromStorageAsStringOrUndefined(window.localStorage, oauthTypeKey);
Expand Down
2 changes: 2 additions & 0 deletions common/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export * from "./dto/AppSettingsDto.js";
export * from "./dto/HyperlinkDto.js";
export * from "./dto/OAuthProvidersDto.js";
export * from "./dto/SupportedImageMimeType.js";
export * from "./dto/oauth/JwtToken.js";
export * from "./dto/oauth/OAuthCodeDto.js";
export * from "./dto/oauth/OAuthType.js";
export * from "./dto/oauth/OAuthUserInfoDto.js";
Expand All @@ -21,6 +22,7 @@ export * from "./entity/UserRole.js";
export * from "./entity/permission/UserRolePermissions.js";
export * from "./markdown-converter-common.js";
export * from "./util/base64.js";
export * from "./util/cookie-header-helpers.js";
export * from "./util/filesize.js";
export * from "./util/markdown.js";
export * from "./util/mimeType.js";
Expand Down
8 changes: 4 additions & 4 deletions common/src/util/base64.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ export function bytesToBase64URL(bytes: Uint8Array): string {
return bytesToBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}

export function base64ToBytes(base64: string): Uint8Array {
return new Uint8Array(Buffer.from(base64, "base64"));
export function base64ToBuffer(base64: string): Buffer {
return Buffer.from(base64, "base64");
}

export function base64UrlToBytes(base64: string): Uint8Array {
return new Uint8Array(Buffer.from(base64, "base64url"));
export function base64UrlToBuffer(base64: string): Buffer {
return Buffer.from(base64.replace(/-/g, "+").replace(/_/g, "/"), "base64");
}

export function bytesToDataUrl(mimeType: string, bytes: Uint8Array): DataUrl {
Expand Down
39 changes: 39 additions & 0 deletions common/src/util/cookie-header-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Request, Response } from "express";

export class HttpHeader {
public static Response = {
/**
* This header passes a refreshed OAuth token from the server
* back to the client, as soon as the old one expired.
*/
OAUTH_REFRESHED_ID_TOKEN: "X-OAuth-Token",
};
/**
* Headers that are passed from the client to the server
*/
public static Request = {
AUTHORIZATION: "Authorization",
OAUTH_ISSUER: "X-OAuth-Issuer",
OAUTH_TYPE: "X-OAuth-Type",
};
}
export class Cookies {
private static REFRESH_TOKEN = "refresh";

static setRefreshTokenCookie: (res: Response, newRefreshToken: string | undefined) => void = (res, newRefreshToken) => {
if (newRefreshToken) {
res.cookie(Cookies.REFRESH_TOKEN, newRefreshToken, { sameSite: "strict", secure: true, httpOnly: true });
}
};

static getCookies: (req: Request) => { [key: string]: string } = (req) => {
return Object.fromEntries(
req
.header("Cookie")
?.split(";")
?.map((it) => it.split("=", 2))
?.filter((it) => it && it.length === 2 && it.every((x) => x.trim().length > 0))
?.map((it) => [it[0].trim(), it[1].trim()]) ?? [],
);
};
}
2 changes: 1 addition & 1 deletion portal/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ const serverOptions: ServerOptions = {
};

https.createServer(serverOptions, app).listen(5030, () => {
console.log(`Test data portal listening on port ${port}`);
console.log(`Login portal listening on port ${port}`);
});

async function createCertificate(): Promise<Certificate> {
Expand Down
6 changes: 1 addition & 5 deletions server/src/config/cors-config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
import { ClientSettings } from "../settings.js";

// Origins that are allowed to call our site / pull data off of our site!
const corsWhiteList = [
"https://www.fumix.de", //
`${ClientSettings.BASE_URL}`,
`http://127.0.0.1:${ClientSettings.PORT}`,
];
const corsWhiteList = [`${ClientSettings.BASE_URL}`, `http://127.0.0.1:${ClientSettings.PORT}`];

export const corsOptions = {
origin: (origin: string | undefined, callback: (arg0: Error | null, arg1: boolean) => void): void => {
Expand Down
33 changes: 19 additions & 14 deletions server/src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
bytesToDataUrl,
convertToUsername,
Cookies,
DataUrl,
isNeitherNullNorUndefined,
OAuthProvider,
Expand All @@ -10,13 +11,14 @@ import {
OAuthUserInfoDto,
UserInfoOAuthToken,
} from "@fumix/fu-blog-common";
import console from "console";
import express, { Request, Response, Router } from "express";
import fetch from "node-fetch";
import { BaseClient, Issuer, TokenSet } from "openid-client";
import { AppDataSource } from "../data-source.js";
import { OAuthAccountEntity } from "../entity/OAuthAccount.entity.js";
import { UserEntity } from "../entity/User.entity.js";
import { BadRequestError } from "../errors/BadRequestError.js";
import { ForbiddenError } from "../errors/ForbiddenError.js";
import logger from "../logger.js";
import { authMiddleware, checkIdToken } from "../service/middleware/auth.js";
import { OAuthSettings } from "../settings.js";
Expand All @@ -32,7 +34,7 @@ OAuthSettings.PROVIDERS.forEach((p) => {
oauthClients[p.getIdentifier()] = undefined;
});

async function findOAuthClient(provider: OAuthProvider<OAuthType>): Promise<BaseClient> {
export async function findOAuthClient(provider: OAuthProvider<OAuthType>): Promise<BaseClient> {
const existingValue = oauthClients[provider.getIdentifier()];
if (!existingValue) {
try {
Expand Down Expand Up @@ -109,19 +111,20 @@ router.post("/loggedInUser", authMiddleware, async (req, res) => {
*
* This needs an authorization code from the OAuth provider as `code`. To identify the OAuth provider, this also needs `issuer` and `type`.
*/
router.post("/userinfo", async (req, res) => {
router.post("/userinfo", async (req, res, next) => {
const code = req.body.code;
const issuer = req.body.issuer;
const type = req.body.type;
const provider = OAuthSettings.findByTypeAndDomain(type, issuer);
if (!code || !issuer || !type) {
res.status(400).json({ error: "Requires parameters `code`, `issuer` and `type`!" });
next(new BadRequestError("Requires parameters `code`, `issuer` and `type`!"));
} else if (!provider) {
res.status(403).json({ error: "We don't accept logins from OAuth provider " + type + "/" + issuer });
next(new ForbiddenError("We don't accept logins from OAuth provider " + type + "/" + issuer));
} else {
try {
const client = await findOAuthClient(provider);
const tokenSet = await client.callback(OAuthSettings.REDIRECT_URI, client.callbackParams(req));
Cookies.setRefreshTokenCookie(res, tokenSet.refresh_token);
// TODO: Differentiate between being not authorized (403 error) and e.g. connection issues to OAuth server (502 error)
const userInfo = await client.userinfo(tokenSet, { method: "POST", via: "body" });
const dbUser = await AppDataSource.manager.getRepository(OAuthAccountEntity).findOne({
Expand All @@ -141,8 +144,7 @@ router.post("/userinfo", async (req, res) => {
const username = dbUser?.user?.username ?? convertToUsername(userInfo.nickname ?? userInfo.preferred_username ?? fullName);
const email = dbUser?.user?.email ?? userInfo.email;
if (!email) {
console.error("Did not receive an email address for new user!");
res.status(403);
next(new ForbiddenError("Did not receive an email address for new user!"));
return;
}
const result: OAuthUserInfoDto = {
Expand Down Expand Up @@ -182,19 +184,19 @@ router.post("/userinfo", async (req, res) => {
}
});

router.post("/userinfo/register", async (req, res) => {
router.post("/userinfo/register", async (req, res, next) => {
const fullName = req.body.fullName ?? "";
const username = req.body.username;
const profilePictureUrl: DataUrl | undefined = (req.body.profilePictureUrl as DataUrl) ?? undefined;
const savedToken = req.body.savedToken as UserInfoOAuthToken;
if (!savedToken) {
res.status(403).json({ error: "Unauthorized!" });
next(new ForbiddenError("No token given!"));
} else if (!username || username.length < 3 || username.length > 64) {
res.status(400).json({ error: "A username with length between 3 and 64 is required!" });
next(new BadRequestError("A username with length between 3 and 64 is required!"));
} else {
const provider = OAuthSettings.findByTypeAndDomain(savedToken.type, savedToken.issuer);
if (!provider) {
res.status(400).json({ error: "Invalid provider " + savedToken.type + "/" + savedToken.issuer });
next(new BadRequestError("Invalid provider " + savedToken.type + "/" + savedToken.issuer));
} else {
await checkIdToken(savedToken.id_token, provider)
.then(async (it) => {
Expand Down Expand Up @@ -244,13 +246,16 @@ router.post("/userinfo/register", async (req, res) => {

res.status(200).json(result);
})
.catch(() => res.status(403).json({ error: "Unauthorized" }));
.catch((err) => {
logger.error("Error creating OAuth account in DB: " + err);
next(new ForbiddenError());
});
}
}
})
.catch((err) => {
logger.error("Error", err);
res.status(403).json({ error: "Unauthorized" });
logger.error("Error registering new OAuth accout: " + err);
next(new ForbiddenError());
});
}
}
Expand Down
4 changes: 2 additions & 2 deletions server/src/routes/utility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ router.post("/dalleGenerateImage", authMiddleware, async (req, res, next) => {
}

await dallEGenerateImage(body)
.then(([mimeType, bytes]) => {
res.status(200).contentType(mimeType).write(bytes, "binary");
.then(([mimeType, buffer]) => {
res.status(200).contentType(mimeType).write(buffer, "binary");
res.end();
})
.catch((e) => res.status(502).json({ error: e }));
Expand Down
Loading

0 comments on commit 1c27a41

Please sign in to comment.