Skip to content

Commit

Permalink
Add supports for multiple security schemes in http server (eclipse-th…
Browse files Browse the repository at this point in the history
…ingweb#1070)

* feat(http-binding): add supports for multiple security schemes

Note that this means that the server is able to expose Things with different
security requirements. For example, it is possibile to now have a Thing
with `nosec` security scheme and one with `basic` security scheme. As a
side effect, the OAuth example now works as explained in eclipse-thingweb#873.

Fix eclipse-thingweb#204 eclipse-thingweb#873

* refactor(binding-http/http-server): rename supportedSecuritySchemes

* refactor(binding-http/routes/common): rename utility functions

* fix(binding-http/routers): handle cors for no-thing paths

* fixup! fix(binding-http/routers): handle cors for no-thing paths
  • Loading branch information
relu91 authored Sep 8, 2023
1 parent 559978e commit c3c468f
Show file tree
Hide file tree
Showing 18 changed files with 212 additions and 120 deletions.
2 changes: 1 addition & 1 deletion examples/security/oauth/consumer.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ WoTHelpers.fetch("https://localhost:8080/oauth").then((td) => {
WoT.consume(td).then(async (thing) => {
try {
const resp = await thing.invokeAction("sayOk");
const result = resp === null || resp === void 0 ? void 0 : resp.value();
const result = await (resp === null || resp === void 0 ? void 0 : resp.value());
console.log("oAuth token was", result);
} catch (error) {
console.log("It seems that I couldn't access the resource");
Expand Down
16 changes: 9 additions & 7 deletions examples/security/oauth/wot-server-servient-conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,16 @@
"allowSelfSigned": true,
"serverKey": "../privatekey.pem",
"serverCert": "../certificate.pem",
"security": {
"scheme": "oauth2",
"method": {
"name": "introspection_endpoint",
"endpoint": "https://localhost:3000/introspect",
"allowSelfSigned": true
"security": [
{
"scheme": "oauth2",
"method": {
"name": "introspection_endpoint",
"endpoint": "https://localhost:3000/introspect",
"allowSelfSigned": true
}
}
}
]
},
"credentials": {
"urn:dev:wot:oauth:test": {
Expand Down
14 changes: 8 additions & 6 deletions packages/binding-http/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,11 @@ let httpConfig = {
allowSelfSigned: true, // client configuration
serverKey: "privatekey.pem",
serverCert: "certificate.pem",
security: {
scheme: "basic", // (username & password)
},
security: [
{
scheme: "basic", // (username & password)
},
],
};
// add HTTPS binding with configuration
servient.addServer(new HttpServer(httpConfig));
Expand Down Expand Up @@ -182,7 +184,7 @@ The protocol binding can be configured using his constructor or trough servient
allowSelfSigned?: boolean; // Accept self signed certificates
serverKey?: string; // HTTPs server secret key file
serverCert?: string; // HTTPs server certificate file
security?: TD.SecurityScheme; // Security scheme of the server
security?: TD.SecurityScheme[]; // A list of possible security schemes to be used by things exposed by this servient.
baseUri?: string // A Base URI to be used in the TD in cases where the client will access a different URL than the actual machine serving the thing. [See Using BaseUri below]
middleware?: MiddlewareRequestHandler; // the MiddlewareRequestHandler function. See [Adding a middleware] section below.
}
Expand Down Expand Up @@ -225,9 +227,9 @@ The http protocol binding supports a set of security protocols that can be enabl
allowSelfSigned: true,
serverKey: "privatekey.pem",
serverCert: "certificate.pem",
security: {
security: [{
scheme: "basic" // (username & password)
}
}]
}
credentials: {
"urn:dev:wot:org:eclipse:thingweb:my-example-secure": {
Expand Down
164 changes: 98 additions & 66 deletions packages/binding-http/src/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export default class HttpServer implements ProtocolServer {
private readonly address?: string = undefined;
private readonly baseUri?: string = undefined;
private readonly urlRewrite?: Record<string, string> = undefined;
private readonly httpSecurityScheme: string = "NoSec"; // HTTP header compatible string
private readonly supportedSecuritySchemes: string[] = ["nosec"];
private readonly validOAuthClients: RegExp = /.*/g;
private readonly server: http.Server | https.Server;
private readonly middleware: MiddlewareRequestHandler | null = null;
Expand Down Expand Up @@ -174,30 +174,28 @@ export default class HttpServer implements ProtocolServer {

// Auth
if (config.security) {
// storing HTTP header compatible string
switch (config.security.scheme) {
case "nosec":
this.httpSecurityScheme = "NoSec";
break;
case "basic":
this.httpSecurityScheme = "Basic";
break;
case "digest":
this.httpSecurityScheme = "Digest";
break;
case "bearer":
this.httpSecurityScheme = "Bearer";
break;
case "oauth2":
{
this.httpSecurityScheme = "OAuth";
const oAuthConfig = config.security as OAuth2ServerConfig;
this.validOAuthClients = new RegExp(oAuthConfig.allowedClients ?? ".*");
this.oAuthValidator = createValidator(oAuthConfig.method);
}
break;
default:
throw new Error(`HttpServer does not support security scheme '${config.security.scheme}`);
if (config.security.length > 1) {
// clear the default
this.supportedSecuritySchemes = [];
}
for (const securityScheme of config.security) {
switch (securityScheme.scheme) {
case "nosec":
case "basic":
case "digest":
case "bearer":
break;
case "oauth2":
{
const oAuthConfig = securityScheme as OAuth2ServerConfig;
this.validOAuthClients = new RegExp(oAuthConfig.allowedClients ?? ".*");
this.oAuthValidator = createValidator(oAuthConfig.method);
}
break;
default:
throw new Error(`HttpServer does not support security scheme '${securityScheme.scheme}`);
}
this.supportedSecuritySchemes.push(securityScheme.scheme);
}
}
}
Expand Down Expand Up @@ -263,10 +261,6 @@ export default class HttpServer implements ProtocolServer {
}
}

public getHttpSecurityScheme(): string {
return this.httpSecurityScheme;
}

private updateInteractionNameWithUriVariablePattern(
interactionName: string,
uriVariables: PropertyElement["uriVariables"] = {},
Expand Down Expand Up @@ -326,9 +320,11 @@ export default class HttpServer implements ProtocolServer {
// media types
} // addresses

if (this.scheme === "https") {
this.fillSecurityScheme(thing);
if (this.scheme === "http" && Object.keys(thing.securityDefinitions).length !== 0) {
warn(`HTTP Server will attempt to use your security schemes even if you are not using HTTPS.`);
}

this.fillSecurityScheme(thing);
}
}
}
Expand Down Expand Up @@ -506,24 +502,25 @@ export default class HttpServer implements ProtocolServer {
throw new Error("Servient not set");
}

const creds = this.servient.getCredentials(thing.id);

switch (this.httpSecurityScheme) {
case "NoSec":
const credentials = this.servient.retrieveCredentials(thing.id);
// Multiple security schemes are deprecated we are not supporting them. We are only supporting one security value.
const selected = Helpers.toStringArray(thing.security)[0];
const thingSecurityScheme = thing.securityDefinitions[selected];
debug(`Verifying credentials with security scheme '${thingSecurityScheme.scheme}'`);
switch (thingSecurityScheme.scheme) {
case "nosec":
return true;
case "Basic": {
case "basic": {
const basic = bauth(req);
const basicCreds = creds as { username: string; password: string };
return (
creds !== undefined &&
basic !== undefined &&
basic.name === basicCreds.username &&
basic.pass === basicCreds.password
);
if (basic === undefined) return false;
if (!credentials || credentials.length === 0) return false;

const basicCredentials = credentials as { username: string; password: string }[];
return basicCredentials.some((cred) => basic.name === cred.username && basic.pass === cred.password);
}
case "Digest":
case "digest":
return false;
case "OAuth": {
case "oauth2": {
const oAuthScheme = thing.securityDefinitions[thing.security[0] as string] as OAuth2SecurityScheme;

// TODO: Support security schemes defined at affordance level
Expand All @@ -549,31 +546,79 @@ export default class HttpServer implements ProtocolServer {
if (req.headers.authorization === undefined) return false;
// TODO proper token evaluation
const auth = req.headers.authorization.split(" ");
const bearerCredentials = creds as { token: string };
return auth[0] === "Bearer" && creds !== undefined && auth[1] === bearerCredentials.token;

if (auth.length !== 2 || auth[0] !== "Bearer") return false;
if (!credentials || credentials.length === 0) return false;

const bearerCredentials = credentials as { token: string }[];
return bearerCredentials.some((cred) => cred.token === auth[1]);
}
default:
return false;
}
}

private fillSecurityScheme(thing: ExposedThing) {
// User selected one security scheme
if (thing.security) {
// multiple security schemes are deprecated we are not supporting them
const securityScheme = Helpers.toStringArray(thing.security)[0];
const secCandidate = Object.keys(thing.securityDefinitions).find((key) => {
return key === securityScheme;
});

if (!secCandidate) {
throw new Error(
"Security scheme not found in thing security definitions. Thing security definitions: " +
Object.keys(thing.securityDefinitions).join(", ")
);
}

const isSupported = this.supportedSecuritySchemes.find((supportedScheme) => {
const thingScheme = thing.securityDefinitions[secCandidate].scheme;
return thingScheme === supportedScheme.toLocaleLowerCase();
});

if (!isSupported) {
throw new Error(
"Servient does not support thing security schemes. Current scheme supported: " +
this.supportedSecuritySchemes.join(", ")
);
}
// We don't need to do anything else, the user has selected one supported security scheme.
return;
}

// The user let the servient choose the security scheme
if (!thing.securityDefinitions || Object.keys(thing.securityDefinitions).length === 0) {
// We are using the first supported security scheme as default
thing.securityDefinitions = {
[this.supportedSecuritySchemes[0]]: { scheme: this.supportedSecuritySchemes[0] },
};
thing.security = [this.supportedSecuritySchemes[0]];
return;
}

if (thing.securityDefinitions) {
// User provided a bunch of security schemes but no thing.security
// we select one for him. We select the first supported scheme.
const secCandidate = Object.keys(thing.securityDefinitions).find((key) => {
let scheme = thing.securityDefinitions[key].scheme as string;
let scheme = thing.securityDefinitions[key].scheme;
// HTTP Authentication Scheme for OAuth does not contain the version number
// see https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml
// remove version number for oauth2 schemes
scheme = scheme === "oauth2" ? scheme.split("2")[0] : scheme;
return scheme === this.httpSecurityScheme.toLowerCase();
return this.supportedSecuritySchemes.includes(scheme.toLocaleLowerCase());
});

if (!secCandidate) {
throw new Error(
"Servient does not support thing security schemes. Current scheme supported: " +
this.httpSecurityScheme +
" secCandidate " +
Object.keys(thing.securityDefinitions).join(", ")
"Servient does not support any of thing security schemes. Current scheme supported: " +
this.supportedSecuritySchemes.join(",") +
" thing security schemes: " +
Object.values(thing.securityDefinitions)
.map((schemeDef) => schemeDef.scheme)
.join(", ")
);
}

Expand All @@ -582,11 +627,6 @@ export default class HttpServer implements ProtocolServer {
thing.securityDefinitions[secCandidate] = selectedSecurityScheme;

thing.security = [secCandidate];
} else {
thing.securityDefinitions = {
noSec: { scheme: "nosec" },
};
thing.security = ["noSec"];
}
}

Expand All @@ -606,14 +646,6 @@ export default class HttpServer implements ProtocolServer {
);
});

// Set CORS headers
if (this.httpSecurityScheme !== "NoSec" && req.headers.origin) {
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
} else {
res.setHeader("Access-Control-Allow-Origin", "*");
}

const contentTypeHeader = req.headers["content-type"];
let contentType: string = Array.isArray(contentTypeHeader) ? contentTypeHeader[0] : contentTypeHeader;

Expand Down
2 changes: 1 addition & 1 deletion packages/binding-http/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export interface HttpConfig {
allowSelfSigned?: boolean;
serverKey?: string;
serverCert?: string;
security?: TD.SecurityScheme;
security?: TD.SecurityScheme[];
middleware?: MiddlewareRequestHandler;
}

Expand Down
15 changes: 12 additions & 3 deletions packages/binding-http/src/routes/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@
********************************************************************************/
import { IncomingMessage, ServerResponse } from "http";
import { Content, Helpers, ProtocolHelpers, createLoggers } from "@node-wot/core";
import { isEmpty, respondUnallowedMethod, validOrDefaultRequestContentType } from "./common";
import {
isEmpty,
respondUnallowedMethod,
securitySchemeToHttpHeader,
setCorsForThing,
validOrDefaultRequestContentType,
} from "./common";
import HttpServer from "../http-server";

const { error, warn } = createLoggers("binding-http", "routes", "action");
Expand Down Expand Up @@ -61,12 +67,15 @@ export default async function actionRoute(
return;
}
// TODO: refactor this part to move into a common place
setCorsForThing(req, res, thing);
let corsPreflightWithCredentials = false;
if (this.getHttpSecurityScheme() !== "NoSec" && !(await this.checkCredentials(thing, req))) {
const securityScheme = thing.securityDefinitions[Helpers.toStringArray(thing.security)[0]].scheme;

if (securityScheme !== "nosec" && !(await this.checkCredentials(thing, req))) {
if (req.method === "OPTIONS" && req.headers.origin) {
corsPreflightWithCredentials = true;
} else {
res.setHeader("WWW-Authenticate", `${this.getHttpSecurityScheme()} realm="${thing.id}"`);
res.setHeader("WWW-Authenticate", `${securitySchemeToHttpHeader(securityScheme)} realm="${thing.id}"`);
res.writeHead(401);
res.end();
return;
Expand Down
21 changes: 20 additions & 1 deletion packages/binding-http/src/routes/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*
* SPDX-License-Identifier: EPL-2.0 OR W3C-20150513
********************************************************************************/
import { ContentSerdes, Helpers, createLoggers } from "@node-wot/core";
import { ContentSerdes, ExposedThing, Helpers, createLoggers } from "@node-wot/core";
import { IncomingMessage, ServerResponse } from "http";

const { debug, warn } = createLoggers("binding-http", "routes", "common");
Expand Down Expand Up @@ -79,3 +79,22 @@ export function isEmpty(obj: Record<string, unknown>): boolean {
}
return true;
}

export function securitySchemeToHttpHeader(scheme: string): string {
const [first, ...rest] = scheme;
// HTTP Authentication Scheme for OAuth does not contain the version number
// see https://www.iana.org/assignments/http-authschemes/http-authschemes.xhtml
if (scheme === "oauth2") return "OAuth";
return first.toUpperCase() + rest.join("").toLowerCase();
}

export function setCorsForThing(req: IncomingMessage, res: ServerResponse, thing: ExposedThing): void {
const securityScheme = thing.securityDefinitions[Helpers.toStringArray(thing.security)[0]].scheme;
// Set CORS headers
if (securityScheme !== "nosec" && req.headers.origin) {
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
} else {
res.setHeader("Access-Control-Allow-Origin", "*");
}
}
Loading

0 comments on commit c3c468f

Please sign in to comment.