-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Pull the WebAPI component out of @amrc-factoryplus/utilities.
- Loading branch information
Showing
8 changed files
with
384 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
node_modules | ||
package-lock.json | ||
/.idea |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
}, | ||
}, | ||
]; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }); | ||
} | ||
} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |