diff --git a/store-openfga-api/package.json b/store-openfga-api/package.json index 75353ef..42a9909 100644 --- a/store-openfga-api/package.json +++ b/store-openfga-api/package.json @@ -2,11 +2,11 @@ "name": "store-openfga-api", "description": "An OAuth 2.0 API protected by OpenFGA for fine-grained authorization.", "author": "Martin Besozzi )", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "main": "server.js", "scripts": { - "start": "nodemon server.js", + "start": "nodemon src/server.js", "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { diff --git a/store-openfga-api/server.js b/store-openfga-api/server.js deleted file mode 100644 index 3583f51..0000000 --- a/store-openfga-api/server.js +++ /dev/null @@ -1,169 +0,0 @@ -const express = require("express"); -const bodyParser = require("body-parser"); -const cors = require("cors"); -const app = express(); -const port = 8000; -const jwt = require("express-jwt"); -const { decode } = require('jsonwebtoken'); -const jwksRsa = require("jwks-rsa"); -const { OpenFgaApi } = require("@openfga/sdk"); - -app.use(bodyParser.json()); -app.use(cors()); -app.use(express.urlencoded({ extended: true })); - -// Create middleware to validate the JWT using express-jwt -const checkJwt = jwt({ - - secret: jwksRsa.expressJwtSecret({ - cache: true, - rateLimit: true, - jwksRequestsPerMinute: 5, - jwksUri: process.env.OIDC_PROVIDER_JWKS_URI || "http://keycloak:8081/realms/master/protocol/openid-connect/certs" - }), - audience: process.env.OIDC_PROVIDER_AUDIENCE || "http://keycloak:8081/realms/master", - issuer: process.env.OIDC_PROVIDER_DOMAIN || "http://keycloak:8081/realms/master", - algorithms: ["RS256"] -}); - - -const fgaClient = new OpenFgaApi({ - apiScheme: process.env.OPENFGA_API_SCHEME || "http", - apiHost: process.env.OPENFGA_API_HOST || "openfga:8080" -}); - -const getOpenFGAStore = async () => { - console.log("[Store API] Getting the OpenFga store..."); - try { - const { stores } = await fgaClient.listStores(); - for (const store of stores) { - console.log("[Store API] Store found name: " + store.name + " id: " + store.id); - fgaClient.storeId = store.id; - } - } - catch(e){ - console.error(e) - } -} -getOpenFGAStore(); - -const userHasRole = async (userId,roleName) => { - let user = "user:" + userId; - let relationship = "assignee"; - let object = "role:" + roleName; - return await checkTuple(user, relationship, object); -} - -const checkTuple = async function (user, relationship, object) { - console.log("[Store API] Check user: " + user + " rel: " + relationship + " obj: " + object); - try { - if(!fgaClient.storeId){ - console.log("[Store API] Upps OpenFGA is not initialized properly..."); - await getOpenFGAStore(); - if(!fgaClient.storeId){ - throw new Error('Upps OpenFGA is not initialized properly :(') - } - } - - let { allowed } = await fgaClient.check({ - tuple_key: { - user: user, - relation: relationship, - object: object - } - }); - console.log("[Store API] Is the user allowed: " + allowed); - return allowed; - } catch ( e ) { - console.log(e); - return false; - } -} - -app.get("/api/products", async (req, res) => { - const auth = req.get('Authorization'); - const { sub } = decode(auth.split(' ')[1]); - console.log("[Store API] Claim sub: " + sub) - if (await userHasRole(sub, "view-product")) { - res.send(products); - } else { - res.status(403).send(); - } -}); - -app.post("/api/products/:id/publish", async (req, res) => { - const auth = req.get('Authorization'); - const { sub } = decode(auth.split(' ')[1]); - console.log("[Store API] Claim sub: " + sub) - const id = Number(req.params.id); - if (await userHasRole(sub, "edit-product")) { - res.send(200); - } else { - res.status(403).send(); - } -}); - -app.get("/api/products/:id", checkJwt, (req, res) => { - const id = Number(req.params.id); - const product = products.find(event => event.id === id); - res.send(product); -}); - - -app.get("/", (req, res) => { - res.send(`[Store API] API version 1.0.0`); -}); - -// listen on the port -app.listen(port); - - -// Mock data -let products = [ - { - id: 1, - name: 'Glasses Ray Ban', - category: "pre-sale", - description: 'Ray-Ban Black Original Wayfarer Classic Sunglasses', - url : "https://source.unsplash.com/K62u25Jk6vo/600x300", - details : "Brand Ray-Ban
Model Name Stories
Style Stories
Color Shiny Blue/Dark Blue Polarized
Age Range (Description) Adult", - status : "publish" - }, - { - id: 2, - name: 'Apple watch', - category: "pre-sale", - description: 'Apple Watch Series 3 42MM Special Features', - url : "https://source.unsplash.com/2cFZ_FB08UM/600x300", - details : "Brand Apple
Model Name Apple Watch Series
Style GPS
Special Feature Activity Tracker, Heart Rate Monitor, Sleep Monitor, Blood Oxygen", - status : "publish" - }, - { - id: 3, - name: 'Headphones Bose', - category: "pre-sale", - description: 'Bose Noise Cancelling Wireless Headphones 700', - url : "https://source.unsplash.com/vISNAATFXlE/600x300", - details: "Brand Bose
Audio Model Name
Performance ANC HeadphonesColor
Technology Bluetooth 5.0", - status: "published" - }, - { - id: 4, - name: 'Nikon Camera', - category: "pre-sale", - description: "Nikon Camera Z50 Two Lens Coolpix B500", - url : "https://source.unsplash.com/dcgB3CgidlU/600x300", - status : "publish", - details: "Brand Nikon
Model Name Nikon Coolpix B500
Form Factor Point and Shoot
Effective Still Resolution 16 MP", - status : "published" - }, - { - id: 5, - name: 'Chanel N°5 Perfume', - category: "pre-sale", - description: 'Ulric De Varens Gold Issime Pour Elle 75ml Estilo Chanel Nº5.', - url : "https://source.unsplash.com/potCPE_Cw8A/600x300", - details : "Brand CHANEL
Item Form Spray
Item Volume 3.4 Fluid Ounces
Age Range (Description)Adult", - status : "published" - } -]; diff --git a/store-openfga-api/src/config/jwt.config.js b/store-openfga-api/src/config/jwt.config.js new file mode 100644 index 0000000..3c326c4 --- /dev/null +++ b/store-openfga-api/src/config/jwt.config.js @@ -0,0 +1,6 @@ +module.exports = { + jwksUri: process.env.OIDC_PROVIDER_JWKS_URI || "http://localhost:8081/realms/master/protocol/openid-connect/certs", + audience: process.env.OIDC_PROVIDER_AUDIENCE || "account", + issuer: process.env.OIDC_PROVIDER_DOMAIN || "http://localhost:8081/realms/master" +} + diff --git a/store-openfga-api/src/config/openfga.config.js b/store-openfga-api/src/config/openfga.config.js new file mode 100644 index 0000000..405bb0b --- /dev/null +++ b/store-openfga-api/src/config/openfga.config.js @@ -0,0 +1,6 @@ +module.exports = { + apiScheme: process.env.OPENFGA_API_SCHEME || "http", + apiHost: process.env.OPENFGA_API_HOST || "openfga:8080" +} + + diff --git a/store-openfga-api/src/controllers/products.controller.js b/store-openfga-api/src/controllers/products.controller.js new file mode 100644 index 0000000..e139369 --- /dev/null +++ b/store-openfga-api/src/controllers/products.controller.js @@ -0,0 +1,24 @@ +const fga = require('../middlewares/openfga'); + +const productsService = require('../services/products.service'); + +const get = async function(req, res){ + console.log(`[Store API] Getting product id: ${req.params.id}`); + res.send(productsService.get(req.params.id)); +} + +const getAll = async function(req, res){ + console.log('[Store API] Getting products'); + res.send(productsService.getAll()); +} + +const publish = async function(req, res){ + console.log(`[Store API] Publishing product id: ${req.params.id}`); + res.send(productsService.publish(req.params.id)); +} + +module.exports = { + get, + getAll, + publish +}; \ No newline at end of file diff --git a/store-openfga-api/src/data.js b/store-openfga-api/src/data.js new file mode 100644 index 0000000..79a1782 --- /dev/null +++ b/store-openfga-api/src/data.js @@ -0,0 +1,50 @@ +module.exports = { + Products: [ + { + id: 1, + name: 'Glasses Ray Ban', + category: "pre-sale", + description: 'Ray-Ban Black Original Wayfarer Classic Sunglasses', + url : "https://source.unsplash.com/K62u25Jk6vo/600x300", + details : "Brand Ray-Ban
Model Name Stories
Style Stories
Color Shiny Blue/Dark Blue Polarized
Age Range (Description) Adult", + status : "publish" + }, + { + id: 2, + name: 'Apple watch', + category: "pre-sale", + description: 'Apple Watch Series 3 42MM Special Features', + url : "https://source.unsplash.com/2cFZ_FB08UM/600x300", + details : "Brand Apple
Model Name Apple Watch Series
Style GPS
Special Feature Activity Tracker, Heart Rate Monitor, Sleep Monitor, Blood Oxygen", + status : "publish" + }, + { + id: 3, + name: 'Headphones Bose', + category: "pre-sale", + description: 'Bose Noise Cancelling Wireless Headphones 700', + url : "https://source.unsplash.com/vISNAATFXlE/600x300", + details: "Brand Bose
Audio Model Name
Performance ANC HeadphonesColor
Technology Bluetooth 5.0", + status: "published" + }, + { + id: 4, + name: 'Nikon Camera', + category: "pre-sale", + description: "Nikon Camera Z50 Two Lens Coolpix B500", + url : "https://source.unsplash.com/dcgB3CgidlU/600x300", + status : "publish", + details: "Brand Nikon
Model Name Nikon Coolpix B500
Form Factor Point and Shoot
Effective Still Resolution 16 MP", + status : "published" + }, + { + id: 5, + name: 'Chanel N°5 Perfume', + category: "pre-sale", + description: 'Ulric De Varens Gold Issime Pour Elle 75ml Estilo Chanel Nº5.', + url : "https://source.unsplash.com/potCPE_Cw8A/600x300", + details : "Brand CHANEL
Item Form Spray
Item Volume 3.4 Fluid Ounces
Age Range (Description)Adult", + status : "published" + } + ] +} \ No newline at end of file diff --git a/store-openfga-api/src/middlewares/jwt.js b/store-openfga-api/src/middlewares/jwt.js new file mode 100644 index 0000000..ab120f7 --- /dev/null +++ b/store-openfga-api/src/middlewares/jwt.js @@ -0,0 +1,29 @@ +const jwt = require("express-jwt"); +const { decode } = require('jsonwebtoken'); +const config = require("../config/jwt.config.js"); +const jwksRsa = require("jwks-rsa"); + +validateToken = jwt({ + secret: jwksRsa.expressJwtSecret({ + cache: true, + rateLimit: true, + jwksRequestsPerMinute: 5, + jwksUri: config.jwksUri + }), + audience: config.audience, + issuer: config.issuer, + algorithms: ["RS256"] +}); + +decodeToken = (req, res, next) => { + const auth = req.get('Authorization'); + const { sub } = decode(auth.split(' ')[1]); + req.userId = sub; + return next(); +}; + + +module.exports = { + validateToken, + decodeToken +} \ No newline at end of file diff --git a/store-openfga-api/src/middlewares/openfga.js b/store-openfga-api/src/middlewares/openfga.js new file mode 100644 index 0000000..6453df3 --- /dev/null +++ b/store-openfga-api/src/middlewares/openfga.js @@ -0,0 +1,68 @@ +const { OpenFgaApi } = require("@openfga/sdk"); + +const config = require("../config/openfga.config.js"); +const { all } = require("../routes/products.route.js"); + +const fgaClient = new OpenFgaApi({ + apiScheme: config.apiScheme, + apiHost: config.apiHost +}); + +const discoverStore = async () => { + try { + const { stores } = await fgaClient.listStores(); + for (const store of stores) { + console.log(`[Store API] Store found name: ${store.name} id: ${store.id}`); + fgaClient.storeId = store.id; + } + return fgaClient; + } + catch(e){ + throw new Error('OpenFGA is not initialized properly') + } +} + +const getClient = async () => { + return (fgaClient.storeId) ? fgaClient : await discoverStore(); +} + +const checkTuple = async function (user, relation, object) { + console.log(`[Store API] Check tuple (user: '${user}', rel: '${relation}', obj: '${object}')`); + try { + let client = await getClient(); + let { allowed } = await client.check({ + tuple_key: { + user: user, + relation: relation, + object: object + } + }); + console.log(`[Store API] Check tuple for user: ${user} isAllowed: ${allowed}`); + return allowed; + } catch ( e ) { + console.log(e); + return false; + } +} + +const userHasRole = async (userId, roleName) => { + return await checkTuple( `user:${userId}`, "assignee", `role:${roleName}`); +} + +const checkUserHasRole = (roleName) => { + return async (req, res, next) => { + let allowed = await userHasRole(req.userId, roleName) + + if(allowed) { + next(); + } + else { + res.status(403).send(); + return; + } + } +} + +module.exports = { + checkUserHasRole +} \ No newline at end of file diff --git a/store-openfga-api/src/routes/index.route.js b/store-openfga-api/src/routes/index.route.js new file mode 100644 index 0000000..2509d1a --- /dev/null +++ b/store-openfga-api/src/routes/index.route.js @@ -0,0 +1,10 @@ +const express = require('express'); +const products = require('./products.route'); + +const router = express.Router(); + +router.use('/api/products', products); + +router.get('/', (req, res) => res.send('API version 1.0.0')); + +module.exports = router; \ No newline at end of file diff --git a/store-openfga-api/src/routes/products.route.js b/store-openfga-api/src/routes/products.route.js new file mode 100644 index 0000000..e4e1993 --- /dev/null +++ b/store-openfga-api/src/routes/products.route.js @@ -0,0 +1,35 @@ +const express = require('express'); +const router = express.Router({ mergeParams: true }); + +const jwt = require('../middlewares/jwt'); +const fga = require('../middlewares/openfga'); +const productsController = require('../controllers/products.controller'); + +router.route('/') + .get( + [ + jwt.validateToken, + jwt.decodeToken, + fga.checkUserHasRole("view-product") + ], + productsController.getAll); + +router.route('/:id') + .get( + [ + jwt.validateToken, + jwt.decodeToken, + fga.checkUserHasRole("view-product") + ], + productsController.get); + +router.route('/:id/publish') + .post( + [ + jwt.validateToken, + jwt.decodeToken, + fga.checkUserHasRole("edit-product") + ], + productsController.publish); + +module.exports = router; \ No newline at end of file diff --git a/store-openfga-api/src/server.js b/store-openfga-api/src/server.js new file mode 100644 index 0000000..175431c --- /dev/null +++ b/store-openfga-api/src/server.js @@ -0,0 +1,16 @@ +const express = require("express"); +const bodyParser = require("body-parser"); +const cors = require("cors"); +const app = express(); +const PORT = process.env.PORT || 9091; + +app.use(bodyParser.json()); +app.use(cors()); +app.use(express.urlencoded({ extended: true })); + +const routes = require('./routes/index.route'); +app.use(routes); + +app.listen(PORT, () => { + console.log(`Server is running on port ${PORT}.`); +}); \ No newline at end of file diff --git a/store-openfga-api/src/services/products.service.js b/store-openfga-api/src/services/products.service.js new file mode 100644 index 0000000..234a821 --- /dev/null +++ b/store-openfga-api/src/services/products.service.js @@ -0,0 +1,19 @@ +const data = require('../data'); + +const get = function(_id){ + return getAll().find(getAll => product.id == _id); +} + +const getAll = function(){ + return data.Products; +} + +const publish = function(_id){ + //ToDo: Update product +} + +module.exports = { + get, + getAll, + publish +}; \ No newline at end of file