Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
9 changes: 6 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ help:
echo "make cover-node | run node tests with coverage"
echo "make cover-python | run python tests with coverage"

.aws-sam/build.toml: ./template.yaml api/package-lock.json api/src/package-lock.json chat/dependencies/requirements.txt chat/src/requirements.txt
api: ./api/template.yaml ./api/src/package-lock.json $(wildcard ./api/src/**/*.js)
chat: ./chat/template.yaml ./chat/dependencies/requirements.txt $(wildcard ./chat/src/**/*.py)
av-download: ./av-download/template.yaml ./av-download/lambdas/package-lock.json $(wildcard ./av-download/lambdas/**/*.js)
.aws-sam/build.toml: ./template.yaml api chat av-download
sed -Ei.orig 's/"dependencies"/"devDependencies"/' api/src/package.json
cp api/src/package-lock.json api/src/package-lock.json.orig
cd api/src && npm i --package-lock-only && cd -
Expand All @@ -48,7 +51,7 @@ help:
done
mv api/src/package.json.orig api/src/package.json
mv api/src/package-lock.json.orig api/src/package-lock.json
layers/ffmpeg/bin/ffmpeg:
av-download/layers/ffmpeg/bin/ffmpeg:
mkdir -p av-download/layers/ffmpeg/bin ;\
curl -L https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz | \
tar -C av-download/layers/ffmpeg/bin -xJ --strip-components=1 --wildcards '*/ffmpeg' '*/ffprobe'
Expand Down Expand Up @@ -84,7 +87,7 @@ test-python: deps-python
cd chat && pytest
python-version:
cd chat && python --version
build: layers/ffmpeg/bin/ffmpeg .aws-sam/build.toml
build: av-download/layers/ffmpeg/bin/ffmpeg .aws-sam/build.toml
validate:
cfn-lint template.yaml **/template.yaml --ignore-checks E3510 W1028 W8001
serve-http: deps-node
Expand Down
3,976 changes: 2,783 additions & 1,193 deletions api/package-lock.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions api/src/.eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"env": {
"es2020": true,
"node": true
}
}
29 changes: 20 additions & 9 deletions api/src/api/api-token.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const {
} = require("../environment");
const jwt = require("jsonwebtoken");

const InstitutionProviders = ["nusso"];

function emptyToken() {
return {
iss: dcApiEndpoint(),
Expand Down Expand Up @@ -35,12 +37,17 @@ class ApiToken {
user(user) {
this.token = {
...this.token,
sub: user?.uid,
name: user?.displayName?.[0],
email: user?.mail,
...user,
isLoggedIn: !!user,
primaryAffiliation: user?.primaryAffiliation,
isDevTeam: !!user && user?.uid && devTeamNetIds().includes(user?.uid),
isDevTeam: !!user && user?.sub && devTeamNetIds().includes(user?.sub),
};
return this.update();
}

provider(provider) {
this.token = {
...this.token,
provider: provider,
};
return this.update();
}
Expand Down Expand Up @@ -112,19 +119,23 @@ class ApiToken {
}

isDevTeam() {
return this.token.isDevTeam;
return !!this.token.isDevTeam;
}

isLoggedIn() {
return this.token.isLoggedIn;
return !!this.token.isLoggedIn;
}

isInstitution() {
return InstitutionProviders.includes(this.token.provider);
}

isReadingRoom() {
return this.token.isReadingRoom;
return !!this.token.isReadingRoom;
}

isSuperUser() {
return this.token.isSuperUser;
return !!this.token.isSuperUser;
}

shouldExpire() {
Expand Down
2 changes: 1 addition & 1 deletion api/src/environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ function apiToken() {
iat: Math.floor(Number(new Date()) / 1000),
};

return jwt.sign(token, process.env.API_TOKEN_SECRET);
return jwt.sign(token, apiTokenSecret());
}

function apiTokenName() {
Expand Down
47 changes: 47 additions & 0 deletions api/src/handlers/auth/magic-callback.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
const { verifyMagicToken } = require("./magic-link");
const ApiToken = require("../../api/api-token");

exports.handler = async (event) => {
const token = event.queryStringParameters?.token;
if (!token) {
return {
statusCode: 400,
body: JSON.stringify({ error: "Missing token" }),
headers: {
"Content-Type": "application/json",
},
};
}
try {
const { email, returnUrl } = verifyMagicToken(decodeURIComponent(token));
const user = { sub: email };
console.info("User", user.sub, "logged in via magic link");
event.userToken = new ApiToken().user(user).provider("magic");
return {
statusCode: 302,
headers: {
location: returnUrl,
},
};
} catch (error) {
const errorMessage = error.message;
let statusCode = 500;
switch (error.code) {
case "INVALID_TOKEN_SIGNATURE":
statusCode = 401;
break;
case "TOKEN_EXPIRED":
statusCode = 401;
break;
default:
console.error("Unknown error", error);
}
return {
statusCode,
body: JSON.stringify({ error: errorMessage }),
headers: {
"Content-Type": "application/json",
},
};
}
};
67 changes: 67 additions & 0 deletions api/src/handlers/auth/magic-link.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
const { apiTokenSecret } = require("../../environment");
const crypto = require("crypto");

const TIMESTAMP_SIZE = 6; // 48 bits
const LINK_EXPIRATION = 15 * 60 * 1000; // 15 minutes

const sign = (data) =>
crypto
.createHmac("sha256", apiTokenSecret())
.update(data)
.digest()
.subarray(0, 16);

const error = (message, code) => {
const err = new Error(message);
err.code = code;
return err;
};

exports.createMagicToken = (email, returnUrl, expiration) => {
expiration = expiration || Date.now() + LINK_EXPIRATION;
const expirationBytes = Buffer.alloc(6);
expirationBytes.writeUIntLE(expiration, 0, TIMESTAMP_SIZE);
const payloadBytes = Buffer.from([email, returnUrl].join("|"), "utf-8");

const payload = Buffer.concat([payloadBytes, expirationBytes]);
const signature = sign(payload);

const encodedPayload = payload.toString("base64").replace(/=+$/g, "");
const encodedSignature = signature.toString("base64").replace(/=+$/g, "");

const token = encodedPayload + encodedSignature;
return { token, expiration };
};

exports.verifyMagicToken = (token) => {
const signatureLength = Math.ceil((16 * 8) / 6); // 16 bytes = 22 encoded chars
const encodedPayload = token.slice(0, token.length - signatureLength);
const encodedSignature = token.slice(token.length - signatureLength);

const payload = Buffer.from(encodedPayload, "base64");
const expectedSignature = sign(payload);
const signature = Buffer.from(encodedSignature, "base64");
let verified;
try {
verified = crypto.timingSafeEqual(signature, expectedSignature);
} catch (e) {
verified = false;
}

if (!verified) {
throw error("Invalid token signature", "INVALID_TOKEN_SIGNATURE");
}

const [email, returnUrl] = payload
.subarray(0, -TIMESTAMP_SIZE)
.toString("utf-8")
.split("|");
const expirationBytes = payload.subarray(-TIMESTAMP_SIZE);
const expiration = expirationBytes.readUIntLE(0, TIMESTAMP_SIZE);

if (Date.now() > expiration) {
throw error("Token expired", "TOKEN_EXPIRED");
}

return { email, returnUrl };
};
64 changes: 64 additions & 0 deletions api/src/handlers/auth/magic-login.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
const { dcApiEndpoint, dcUrl } = require("../../environment");
const { createMagicToken } = require("./magic-link");
const { SESClient, SendTemplatedEmailCommand } = require("@aws-sdk/client-ses");

const { MAGIC_LINK_EMAIL_TEMPLATE, REPOSITORY_EMAIL } = process.env;

exports.handler = async (event, context) => {
const callbackUrl = new URL(`${dcApiEndpoint()}/auth/callback/magic`);

const returnUrl =
event.queryStringParameters?.goto ||
event.headers?.referer ||
`${dcApiEndpoint()}/auth/whoami`;

const email = event.queryStringParameters?.email;
if (!email) {
return {
statusCode: 400,
body: JSON.stringify({ error: "Email is required" }),
headers: {
"Content-Type": "application/json",
},
};
}

const { token, expiration } = createMagicToken(email, returnUrl);
callbackUrl.searchParams.set("token", token);
const magicLink = callbackUrl.toString();

const sesClient = context?.injections?.sesClient || new SESClient({});

const cmd = new SendTemplatedEmailCommand({
Destination: { ToAddresses: [email] },
TemplateData: JSON.stringify({ magicLink }),
Source: `Northwestern University Libraries <${REPOSITORY_EMAIL}>`,
Template: MAGIC_LINK_EMAIL_TEMPLATE,
});

try {
await sesClient.send(cmd);
console.info("Magic link sent to <%s>", email);
} catch (err) {
console.error("Failed to send template email", err);
return {
statusCode: 500,
body: JSON.stringify({
error: "Failed to send email",
reason: err.message,
}),
headers: {
"Content-Type": "application/json",
},
};
}

return {
statusCode: 200,
body: JSON.stringify({
message: "Magic link sent",
email,
expires: new Date(expiration),
}),
};
};
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
const axios = require("axios").default;
const cookie = require("cookie");
const { wrap } = require("./middleware");
const ApiToken = require("../api/api-token");
const Honeybadger = require("../honeybadger-setup");
const ApiToken = require("../../api/api-token");
const Honeybadger = require("../../honeybadger-setup");

const BAD_DIRECTORY_SEARCH_FAULT =
/Reason: ResponseCode 404 is treated as error/;

/**
* NUSSO auth callback
*/
exports.handler = wrap(async (event) => {
exports.handler = async (event) => {
const returnPath = Buffer.from(
decodeURIComponent(event.cookieObject.redirectUrl),
"base64"
).toString("utf8");

const user = await redeemSsoToken(event);
if (user) {
event.userToken = new ApiToken().user(user);
console.info("User", user.sub, "logged in via nusso");
event.userToken = new ApiToken().user(user).provider("nusso");
return {
statusCode: 302,
cookies: [
Expand All @@ -32,7 +32,7 @@ exports.handler = wrap(async (event) => {
};
}
return { statusCode: 400 };
});
};

async function invokeNuApi(path, headers) {
const url = new URL(process.env.NUSSO_BASE_URL);
Expand All @@ -49,6 +49,15 @@ async function getNetIdFromToken(nusso) {
return response?.data?.netid;
}

function transform(user) {
return {
sub: user?.uid,
name: user?.displayName?.[0],
email: user?.mail,
primaryAffiliation: user?.primaryAffiliation,
};
}

async function redeemSsoToken(event) {
const nusso = event.cookieObject.nusso;
const netid = await getNetIdFromToken(nusso);
Expand All @@ -57,12 +66,13 @@ async function redeemSsoToken(event) {
const response = await invokeNuApi(
`/directory-search/res/netid/bas/${netid}`
);
return fillInBlanks({ ...response.data.results[0], uid: netid });
const user = fillInBlanks({ ...response.data.results[0], uid: netid });
return transform(user);
} catch (err) {
if (
BAD_DIRECTORY_SEARCH_FAULT.test(err?.response?.data?.fault?.faultstring)
) {
return fillInBlanks({ uid: netid });
return transform(fillInBlanks({ uid: netid }));
}
await Honeybadger.notifyAsync(err, { tags: ["auth", "upstream"] });
console.error(err.response.data);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
const { dcApiEndpoint } = require("../environment");
const { dcApiEndpoint } = require("../../environment");
const axios = require("axios").default;
const cookie = require("cookie");
const { wrap } = require("./middleware");
const Honeybadger = require("../honeybadger-setup");
const Honeybadger = require("../../honeybadger-setup");

/**
* Performs NUSSO login
*/
exports.handler = wrap(async (event) => {
const callbackUrl = `${dcApiEndpoint()}/auth/callback`;
exports.handler = async (event) => {
const callbackUrl = `${dcApiEndpoint()}/auth/callback/nusso`;
const url = `${process.env.NUSSO_BASE_URL}get-ldap-redirect-url`;
const returnPath =
event.queryStringParameters?.goto ||
Expand Down Expand Up @@ -48,4 +47,4 @@ exports.handler = wrap(async (event) => {
statusCode: 401,
};
}
});
};
Loading