Skip to content

Commit

Permalink
Initial implementation (#1)
Browse files Browse the repository at this point in the history
Pull the WebAPI component out of @amrc-factoryplus/utilities.
  • Loading branch information
amrc-benmorrow authored Oct 28, 2024
2 parents bb24624 + 27d04ed commit ad2d5f0
Show file tree
Hide file tree
Showing 8 changed files with 384 additions and 0 deletions.
42 changes: 42 additions & 0 deletions .github/workflows/new_release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: New Release

env:
REGISTRY: ghcr.io

on:
release:
types: [ published ]
# Trigger only when a release with tag v*.*.* is published
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'

jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20.x'

- name: Update version in package.json
run: |
CURRENT_TAG=${GITHUB_REF#refs/tags/}
echo "Current tag: $CURRENT_TAG"
VERSION="${CURRENT_TAG#v}"
echo "Updating version to: $VERSION"
jq ".version = \"$VERSION\"" package.json > package.json.tmp
mv package.json.tmp package.json
- name: Install Dependencies
run: npm install --verbose

- name: Publish Package
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
npm publish --access=public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
package-lock.json
/.idea
23 changes: 23 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Makefile for JS libraries

base= ghcr.io/amrc-factoryplus/acs-base-js-build:v3.0.0

all:

.PHONY: all dev

check-committed:
[ -z "$$(git status --porcelain)" ] || (git status; exit 1)

lint:
npx eslint lib

publish: check-committed lint
npm version prerelease
npm publish

amend:
git commit -C HEAD -a --amend

dev:
docker run --rm -ti -v $$(pwd):/local -w /local ${base} /bin/sh
22 changes: 22 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import globals from "globals";
import js from "@eslint/js";

export default [
js.configs.recommended,

{
languageOptions: {
globals: {
...globals.node,
},

ecmaVersion: "latest",
sourceType: "module",
},

rules: {
"no-unreachable": "warn",
"no-unused-vars": "warn",
},
},
];
162 changes: 162 additions & 0 deletions lib/auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
* Factory+ Service HTTP API
* HTTP Auth verification
* Copyright 2024 University of Sheffield
*/

import crypto from "crypto";

import GSS from "gssapi.js";
import { pathToRegexp } from "path-to-regexp";

const Auth_rx = /^([A-Za-z]+) +([A-Za-z0-9._~+/=-]+)$/;
const SESSION_LENGTH = 3*3600*1000;
const DATE_FUZZ = 1*60*1000;

/* This class handles HTTP auth; it accepts Basic and Negotiate and
* verifies them against Kerberos. It also sets up /token as a POST
* endpoint which issues a token, and accepts those tokens as Bearer
* auth. */
export class FplusHttpAuth {
/* Opts is an object; properties as follows. ? means optional.
* realm: Our Kerberos realm
* hostname: A hostname we have a Kerberos key for.
* keytab: The path to our keytab file (our private key).
* session_length?: How long should tokens be valid for? (ms)
*
* realm and hostname are needed to support Basic auth. Client
* usernames will have `@${realm}` added if they don't include an @,
* and we need a ticket for `HTTP/${hostname}@${realm}` available in
* the keytab to use to verify the client's credentials.
*/
constructor (opts) {
this.realm = opts.realm;
this.principal = `HTTP/${opts.hostname}`;
this.keytab = opts.keytab;
this.session_length = opts.session_length ?? SESSION_LENGTH;
this.log = opts.log
?? ((req, res, ...args) => console.log(...args));

if (opts.public) {
this.public = pathToRegexp(opts.public);
}

this.tokens = new Map();
}

/* Set up our auth middleware on an express app, and install the
* /token endpoint. */
setup (app) {
app.use(this.auth.bind(this));
app.post("/token", this.token.bind(this));
}

async auth (req, res, next) {
const ctx = {
req, res,
log: (...a) => this.log(req, res, ...a),
};
let client;

try {
const auth = req.header("Authorization");
if (!auth) {
if (this.public && this.public.test(req.path)) {
ctx.log("Allowing public access");
req.auth = null;
return next();
}
throw "No HTTP auth provided";
}

const [, scheme, creds] = auth.match(Auth_rx) ?? ["Unknown"];
ctx.creds = creds;

ctx.log(`Handling ${scheme} auth`);
switch (scheme) {
case "Basic":
client = await this.auth_basic(ctx);
break;
case "Negotiate":
client = await this.auth_gssapi(ctx);
break;
case "Bearer":
client = await this.auth_bearer(ctx);
break;
default:
throw `Unknown authentication scheme ${scheme}`;
}
}
catch (e) {
ctx.log(`Auth failed: ${e}`);
return res
.status(401)
.header("WWW-Authenticate", `Basic realm="Factory+"`)
.end();
}

ctx.log(`Auth succeeded for [${client}]`);
req.auth = client;
next();
}

async auth_basic (ctx) {
const [, user, pass] = atob(ctx.creds).match(/^([^:]+):(.+)/);

const client = user.includes("@")
? user : `${user}@${this.realm}`;

await GSS.verifyCredentials(client, pass, {
keytab: `FILE:${this.keytab}`,
serverPrincipal: this.principal,
});
return client;
}

async auth_gssapi (ctx) {
const cli_tok = Buffer.from(ctx.creds, "base64");

/* This is an appalling API... */
GSS.setKeytabPath(this.keytab);
const srv_ctx = GSS.createServerContext();
const srv_tok = await GSS.acceptSecContext(srv_ctx, cli_tok);

const client = srv_ctx.clientName();
if (!srv_ctx.isComplete())
throw `GSSAPI auth failed for ${client}`;

ctx.res.header("WWW-Authenticate",
"Negotiate " + srv_tok.toString("base64"));
return client;
}

async auth_bearer (ctx) {
const { creds } = ctx;
const client = this.tokens.get(creds);

if (!client) throw "Bad token";
if (client.expiry < Date.now() + DATE_FUZZ) {
this.tokens.delete(creds);
throw "Expired token";
}

return client.principal;
}

token (req, res) {
const token = crypto.randomBytes(66).toString("base64");
const expiry = Date.now() + this.session_length;
this.tokens.set(token, {
principal: req.auth,
expiry,
});

this.log(req, res, "Created token %o", {
principal: req.auth,
expiry: new Date(expiry),
});

return res.json({ token, expiry });
}
}

8 changes: 8 additions & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* Factory+ Service HTTP API
* Library exports
* Copyright 2024 University of Sheffield
*/

export * from "./auth.js";
export * from "./webapi.js";
97 changes: 97 additions & 0 deletions lib/webapi.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* Factory+ Service HTTP API
* API framework class
* Copyright 2024 University of Sheffield
*/

import http from "http";

import express from "express";
import createError from "http-errors";
import cors from "cors";

import { FplusHttpAuth } from "./auth.js";

export class WebAPI {
constructor (opts) {
this.max_age = opts.max_age;
this.ping_response = opts.ping;
this.port = opts.http_port || 80;
this.routes = opts.routes;
this.log = opts.debug?.bound("http") ?? console.log;

this.auth = new FplusHttpAuth({
...opts,
log: (req, res, ...args) => (req.log ?? this.log)(...args),
});
this.app = express();
}

async init () {
let app = this.app;

/* CORS */
app.use(cors({ credentials: true, maxAge: 86400 }));

/* Body decoders. These will only decode request bodies of the
* appropriate content-type. */
app.use(express.json());
app.use(express.text());

/* Logging */
app.use((req, res, next) => {
let buf = [];
req.log = (...args) => buf.push(args);
req.log(">>> %s %s", req.method, req.originalUrl);
res.on("finish", () => {
req.log("<<< %s %s",
res.statusCode, res.getHeader("Content-Type"));
buf.forEach(a => this.log(...a));
});
next();
});

this.auth.setup(app);
app.get("/ping", this.ping.bind(this));

/* Set caching */
if (this.max_age) {
const cc = `max-age=${this.max_age}`
app.use((req, res, next) => {
if (req.method == "GET")
res.header("Cache-Control", cc);
next();
});
}

/* Set up real routes */
this.routes(app);

/* Catch-all 404 */
app.use((req, res, next) => next(createError(404)));

/* Error handling */
app.use((err, req, res, next) => {
console.error(err);
if (res.headersSent)
return next(err);
res.status(err.status || 500)
.type("text/plain")
.send(`Server error: ${err.message}`);
});

this.http = http.createServer(app);

return this;
}

run () {
this.log("Creating HTTP server on port %s", this.port);
this.http.listen(this.port);
}

ping (req, res) {
res.json(this.ping_response);
}
}

27 changes: 27 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "@amrc-factoryplus/service-api",
"version": "1.0.0",
"description": "",
"main": "lib/index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"content-type": "^1.0.5",
"cors": "^2.8.5",
"express": "^4.18.2",
"gssapi.js": "^2.0.1",
"optional-js": "^2.3.0",
"path-to-regexp": "^6.2.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.13.0",
"eslint": "^9.13.0",
"globals": "^15.11.0"
}
}

0 comments on commit ad2d5f0

Please sign in to comment.