diff --git a/node/src/api/api-token.js b/node/src/api/api-token.js index 51a6f49..03d1958 100644 --- a/node/src/api/api-token.js +++ b/node/src/api/api-token.js @@ -10,6 +10,7 @@ function emptyToken() { isLoggedIn: false, }; } + class ApiToken { constructor(signedToken) { if (signedToken) { @@ -75,6 +76,10 @@ class ApiToken { return this.update(); } + expireAt(dateTime) { + this.token.exp = Math.floor(Number(dateTime) / 1000); + } + update() { this._updated = true; return this; diff --git a/node/src/handlers/get-auth-token.js b/node/src/handlers/get-auth-token.js new file mode 100644 index 0000000..c3ae45d --- /dev/null +++ b/node/src/handlers/get-auth-token.js @@ -0,0 +1,51 @@ +const { wrap } = require("./middleware"); +const Honeybadger = require("../honeybadger-setup"); + +const DEFAULT_TTL = 86400; // one day +const MAX_TTL = DEFAULT_TTL * 7; // one week; + +const makeError = (code, message) => { + return { + statusCode: code, + headers: { + "Content-Type": "text/plain", + }, + body: message, + }; +}; + +const present = (value) => + value !== undefined && value !== null && value !== ""; + +/** + * Token - Returns auth token for current user with requested expiration + * + */ +exports.handler = wrap(async (event) => { + try { + const ttl = event.queryStringParameters?.ttl; + if (present(ttl) && ttl.match(/\D/)) { + return makeError(400, `'${ttl}' is not a valid value for ttl`); + } + const ttl_in_seconds = Number(ttl) || DEFAULT_TTL; + if (ttl_in_seconds > MAX_TTL) { + return makeError(400, `ttl cannot exceed ${MAX_TTL} seconds`); + } + + const token = event.userToken; + const expiration = new Date(new Date().getTime() + ttl_in_seconds * 1000); + expiration.setMilliseconds(0); + token.expireAt(expiration); + + return { + statusCode: 200, + body: JSON.stringify({ + token: token.sign(), + expires: expiration.toISOString(), + }), + }; + } catch (error) { + await Honeybadger.notifyAsync(error); + return makeError(401, "Error verifying API token: " + error.message); + } +}); diff --git a/node/test/integration/get-auth-token.test.js b/node/test/integration/get-auth-token.test.js new file mode 100644 index 0000000..cd25500 --- /dev/null +++ b/node/test/integration/get-auth-token.test.js @@ -0,0 +1,136 @@ +"use strict"; + +const chai = require("chai"); +const expect = chai.expect; +const jwt = require("jsonwebtoken"); + +const getAuthTokenHandler = requireSource("handlers/get-auth-token"); + +// Utility functions to calculate the number of seconds or milliseconds from the epoch plus +// an offset in seconds, but with one-second resolution +const fromNowSeconds = (seconds) => + Math.floor((new Date().getTime() + seconds * 1000) / 1000); + +describe("auth token", function () { + helpers.saveEnvironment(); + let payload; + + beforeEach(() => { + payload = { + iss: "https://example.com", + sub: "user123", + name: "Some One", + exp: Math.floor(Number(new Date()) / 1000) + 12 * 60 * 60, + iat: Math.floor(Number(new Date()) / 1000), + email: "user@example.com", + }; + }); + + it("works with anonymous users", async () => { + const event = helpers.mockEvent("GET", "/auth/token").render(); + + const expectedExpiration = fromNowSeconds(86400); + const result = await getAuthTokenHandler.handler(event); + expect(result.statusCode).to.eq(200); + const body = JSON.parse(result.body); + + // Built-in Date will be in millis and our expiration is in seconds + expect(Date.parse(body.expires)).to.be.within( + (expectedExpiration - 1) * 1000, + (expectedExpiration + 1) * 1000 + ); + + const resultToken = jwt.verify(body.token, process.env.API_TOKEN_SECRET); + expect(resultToken.exp).to.be.within( + expectedExpiration - 1, + expectedExpiration + 1 + ); + expect(resultToken.isLoggedIn).to.eq(false); + }); + + it("returns a token with a default ttl of 1 day", async () => { + const token = jwt.sign(payload, process.env.API_TOKEN_SECRET); + const event = helpers + .mockEvent("GET", "/auth/token") + .headers({ + Cookie: `${process.env.API_TOKEN_NAME}=${token};`, + }) + .render(); + + const expectedExpiration = fromNowSeconds(86400); + const result = await getAuthTokenHandler.handler(event); + expect(result.statusCode).to.eq(200); + const body = JSON.parse(result.body); + + // Built-in Date will be in millis and our expiration is in seconds + expect(Date.parse(body.expires)).to.be.within( + (expectedExpiration - 1) * 1000, + (expectedExpiration + 1) * 1000 + ); + + const resultToken = jwt.verify(body.token, process.env.API_TOKEN_SECRET); + expect(resultToken.exp).to.be.within( + expectedExpiration - 1, + expectedExpiration + 1 + ); + expect(resultToken.name).to.eq("Some One"); + }); + + it("returns a token with the requested ttl", async () => { + const token = jwt.sign(payload, process.env.API_TOKEN_SECRET); + const ttl = 3600 * 18; // 18 hours + const event = helpers + .mockEvent("GET", "/auth/token") + .queryParams({ ttl: ttl.toString() }) + .headers({ + Cookie: `${process.env.API_TOKEN_NAME}=${token};`, + }) + .render(); + + const expectedExpiration = fromNowSeconds(ttl); + const result = await getAuthTokenHandler.handler(event); + expect(result.statusCode).to.eq(200); + const body = JSON.parse(result.body); + + // Built-in Date will be in millis and our expiration is in seconds + expect(Date.parse(body.expires)).to.be.within( + (expectedExpiration - 1) * 1000, + (expectedExpiration + 1) * 1000 + ); + + const resultToken = jwt.verify(body.token, process.env.API_TOKEN_SECRET); + expect(resultToken.exp).to.be.within( + expectedExpiration - 1, + expectedExpiration + 1 + ); + expect(resultToken.name).to.eq("Some One"); + }); + + it("rejects a request with a non-numeric ttl", async () => { + const token = jwt.sign(payload, process.env.API_TOKEN_SECRET); + const event = helpers + .mockEvent("GET", "/auth/token") + .queryParams({ ttl: "blargh" }) + .headers({ + Cookie: `${process.env.API_TOKEN_NAME}=${token};`, + }) + .render(); + const result = await getAuthTokenHandler.handler(event); + expect(result.statusCode).to.eq(400); + expect(result.body).to.eq("'blargh' is not a valid value for ttl"); + }); + + it("rejects a request with a ttl that's too high", async () => { + const token = jwt.sign(payload, process.env.API_TOKEN_SECRET); + const event = helpers + .mockEvent("GET", "/auth/token") + .queryParams({ ttl: "864000" }) + .headers({ + Cookie: `${process.env.API_TOKEN_NAME}=${token};`, + }) + .render(); + const result = await getAuthTokenHandler.handler(event); + expect(result.statusCode).to.eq(400); + expect(result.body).to.eq("ttl cannot exceed 604800 seconds"); + }); +}); diff --git a/template.yaml b/template.yaml index 67fd7ad..a4aeda2 100644 --- a/template.yaml +++ b/template.yaml @@ -196,6 +196,30 @@ Resources: ApiId: !Ref dcApi Path: /auth/logout Method: GET + getAuthTokenFunction: + Type: AWS::Serverless::Function + Properties: + Handler: handlers/get-auth-token.handler + Description: Function to retrieve raw JWT. + #* Layers: + #* - !Ref apiDependencies + Environment: + Variables: + NUSSO_API_KEY: !Ref NussoApiKey + NUSSO_BASE_URL: !Ref NussoBaseUrl + Events: + ApiGet: + Type: HttpApi + Properties: + ApiId: !Ref dcApi + Path: /auth/token + Method: GET + ApiHead: + Type: HttpApi + Properties: + ApiId: !Ref dcApi + Path: /auth/token + Method: HEAD getAuthWhoAmIFunction: Type: AWS::Serverless::Function Properties: