Skip to content

Commit

Permalink
feat(http-binding): add supports for multiple security schemes
Browse files Browse the repository at this point in the history
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
  • Loading branch information
relu91 committed Sep 5, 2023
1 parent 67f3a44 commit 680d807
Show file tree
Hide file tree
Showing 16 changed files with 210 additions and 119 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 supportedSecurityScheme: 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.supportedSecurityScheme = [];
}
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.supportedSecurityScheme.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.supportedSecurityScheme.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.supportedSecurityScheme.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.supportedSecurityScheme[0]]: { scheme: this.supportedSecurityScheme[0] },
};
thing.security = [this.supportedSecurityScheme[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.supportedSecurityScheme.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.supportedSecurityScheme.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 @@ -56,12 +62,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 680d807

Please sign in to comment.