diff --git a/README.md b/README.md index 10e1e97d..19de0c9b 100644 --- a/README.md +++ b/README.md @@ -51,11 +51,15 @@ We use a self-hosted version of [LogTo](https://logto.io/), which supports OpenI 1. `ENABLE_AUTHENTICATION`: Turns API authentication guards on/off (Default: `false`). If `ENABLE_AUTHENTICATION=false`, then define below environment variable in `.env` file: - `DEFAULT_CUSTOMER_ID`: Customer/user in LogTo to use for unauthenticated users. 2. `LOGTO_ENDPOINT`: API endpoint for LogTo server -3. `LOGTO_RESOURCE_URL`: API resource associated with application +3. `LOGTO_DEFAULT_RESOURCE_URL`: Usually it will be a root of all API resources. All the resourceAPI will be constructed on top of that. 4. `LOGTO_APP_ID`: Application ID from LogTo. For now, Application is supposed to be a TraditionalWeb 5. `LOGTO_APP_SECRET`: Application secret. Also should encrypted in deployment -6. `ALL_SCOPES`: List of all scopes. Should be a string with scopes divided by whitespace, like `account:create account:read did:create` -7. `COOKIE_SECRET`: Secret for cookie encryption. +6. `LOGTO_M2M_APP_ID`: Machine-to-machine Application ID +7. `LOGTO_M2M_APP_SECRET`: Machine-to-machine Application secret +8. `LOGTO_MANAGEMENT_API`: URL of management API for LogTo (default is `https://default.logto.app/api`) +9. `ALLOWED_ORIGINS`: CORS allowed origins used in the app +10. `DEFAULT_CUSTOMER_ID`: Customer/user in LogTo to use for unauthenticated users +11. `COOKIE_SECRET`: Secret for cookie encryption. ### 3rd Party Connectors diff --git a/docker/Dockerfile b/docker/Dockerfile index ccbd4a9c..6c66553a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -58,13 +58,15 @@ ARG EXTERNAL_DB_CERT # LogTo: build-time ARG ENABLE_AUTHENTICATION=false ARG LOGTO_ENDPOINT -ARG LOGTO_RESOURCE_URL +ARG LOGTO_DEFAULT_RESOURCE_URL ARG LOGTO_APP_ID ARG LOGTO_APP_SECRET ARG ALLOWED_ORIGINS ARG DEFAULT_CUSTOMER_ID -ARG ALL_SCOPES ARG COOKIE_SECRET +ARG LOGTO_M2M_APP_ID +ARG LOGTO_M2M_APP_SECRET +ARG LOGTO_MANAGEMENT_API # Verida connector: build-time ARG ENABLE_VERIDA_CONNECTOR=false @@ -93,12 +95,14 @@ ENV EXTERNAL_DB_CERT ${EXTERNAL_DB_CERT} ENV ENABLE_AUTHENTICATION ${ENABLE_AUTHENTICATION} ENV DEFAULT_CUSTOMER_ID ${DEFAULT_CUSTOMER_ID} ENV LOGTO_ENDPOINT ${LOGTO_ENDPOINT} -ENV LOGTO_RESOURCE_URL ${LOGTO_RESOURCE_URL} +ENV LOGTO_DEFAULT_RESOURCE_URL ${LOGTO_DEFAULT_RESOURCE_URL} ENV LOGTO_APP_ID ${LOGTO_APP_ID} ENV LOGTO_APP_SECRET ${LOGTO_APP_SECRET} ENV ALLOWED_ORIGINS ${ALLOWED_ORIGINS} -ENV ALL_SCOPES ${ALL_SCOPES} ENV COOKIE_SECRET ${COOKIE_SECRET} +ENV LOGTO_M2M_APP_ID ${LOGTO_M2M_APP_ID} +ENV LOGTO_M2M_APP_SECRET ${LOGTO_M2M_APP_SECRET} +ENV LOGTO_MANAGEMENT_API ${LOGTO_MANAGEMENT_API} # Environment variables: Verida connector ENV ENABLE_VERIDA_CONNECTOR ${ENABLE_VERIDA_CONNECTOR} diff --git a/example.env b/example.env index bb83e5a6..74e49c50 100644 --- a/example.env +++ b/example.env @@ -11,19 +11,20 @@ EXTERNAL_DB_ENCRYPTION_KEY="" EXTERNAL_DB_CERT="" # OpenId -LOGTO_RESOURCE_URL='http://localhost:8787' +LOGTO_DEFAULT_RESOURCE_URL='http://localhost:8787' # LogTo LOGTO_ENDPOINT='http://localhost:3001' LOGTO_APP_ID='ldfsr...rq432' LOGTO_APP_SECRET='sdf...sdf' +LOGTO_M2M_APP_ID="aaaa.....ddddd" +LOGTO_M2M_APP_SECRET="aaaa.....ddddd" +LOGTO_MANAGEMENT_API="https://default.logto.app/api" COOKIE_SECRET='sdf...sdf' # Authentication ENABLE_AUTHENTICATION="false" DEFAULT_CUSTOMER_ID="default customer id" -ALL_SCOPES="account:create did:create credential:issue" - # verida ENABLE_VERIDA_CONNECTOR="string,default:false" diff --git a/package-lock.json b/package-lock.json index e3b9021c..601b1d13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cheqd/credential-service", - "version": "2.3.1", + "version": "2.1.0-develop.25", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@cheqd/credential-service", - "version": "2.3.1", + "version": "2.1.0-develop.25", "license": "Apache-2.0", "dependencies": { "@cheqd/did-provider-cheqd": "3.3.1", @@ -34,6 +34,7 @@ "express-session": "^1.17.3", "express-validator": "^7.0.1", "helmet": "^7.0.0", + "json-stringify-safe": "^5.0.1", "node-cache": "^5.1.2", "pg": "^8.11.0", "pg-connection-string": "^2.6.0", @@ -56,6 +57,7 @@ "@types/express": "^4.17.17", "@types/express-session": "^1.17.7", "@types/helmet": "^4.0.0", + "@types/json-stringify-safe": "^5.0.0", "@types/node": "^20.2.5", "@types/secp256k1": "^4.0.3", "@types/swagger-ui-express": "^4.1.3", @@ -9659,6 +9661,12 @@ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==" }, + "node_modules/@types/json-stringify-safe": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/json-stringify-safe/-/json-stringify-safe-5.0.0.tgz", + "integrity": "sha512-UUA1sH0RSRROdInuDOA1yoRzbi5xVFD1RHCoOvNRPTNwR8zBkJ/84PZ6NhKVDtKp0FTeIccJCdQz1X2aJPr4uw==", + "dev": true + }, "node_modules/@types/long": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", @@ -20848,8 +20856,7 @@ "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" }, "node_modules/json.sortify": { "version": "2.2.2", @@ -41587,6 +41594,12 @@ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==" }, + "@types/json-stringify-safe": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/json-stringify-safe/-/json-stringify-safe-5.0.0.tgz", + "integrity": "sha512-UUA1sH0RSRROdInuDOA1yoRzbi5xVFD1RHCoOvNRPTNwR8zBkJ/84PZ6NhKVDtKp0FTeIccJCdQz1X2aJPr4uw==", + "dev": true + }, "@types/long": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", @@ -50527,8 +50540,7 @@ "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" }, "json.sortify": { "version": "2.2.2", diff --git a/package.json b/package.json index 97849022..fc167c1b 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "express-session": "^1.17.3", "express-validator": "^7.0.1", "helmet": "^7.0.0", + "json-stringify-safe": "^5.0.1", "node-cache": "^5.1.2", "pg": "^8.11.0", "pg-connection-string": "^2.6.0", @@ -90,6 +91,7 @@ "@types/express": "^4.17.17", "@types/express-session": "^1.17.7", "@types/helmet": "^4.0.0", + "@types/json-stringify-safe": "^5.0.0", "@types/node": "^20.2.5", "@types/secp256k1": "^4.0.3", "@types/swagger-ui-express": "^4.1.3", diff --git a/src/app.ts b/src/app.ts index 4b1cd214..e6affeb7 100644 --- a/src/app.ts +++ b/src/app.ts @@ -23,8 +23,11 @@ dotenv.config() import { UserInfo } from './controllers/user_info.js' import path from 'path' -const swagger_options = { - customJs: '/static/custom-button.js', +let swagger_options = {} +if (process.env.ENABLE_AUTHENTICATION === 'true') { + swagger_options = { + customJs: '/static/custom-button.js', + } } class App { @@ -55,9 +58,10 @@ class App { this.express.use(cookieParser()) if (process.env.ENABLE_AUTHENTICATION === 'true') { this.express.use(session({secret: process.env.COOKIE_SECRET, cookie: { maxAge: 14 * 24 * 60 * 60 }})) + this.express.use(Authentication.withLogtoWrapper) + this.express.use(Authentication.guard) + this.express.use(Authentication.wrapperHandleAuthRoutes) } - this.express.use(handleAuthRoutes(configLogToExpress)) - this.express.use(withLogto(configLogToExpress)) this.express.use(express.text()) this.express.use( @@ -67,7 +71,6 @@ class App { return res.send(swaggerUi.generateHTML(swaggerJSONDoc, swagger_options)) } ) - this.express.use(Authentication.guard) this.express.use(Authentication.handleError) this.express.use(Authentication.accessControl) } @@ -85,9 +88,9 @@ class App { app.post('/credential/suspend', new CredentialController().suspend) app.post('/credential/reinstate', new CredentialController().reinstate) - //revocation - app.post('/revocation/statusList2021/create', RevocationController.didValidator, RevocationController.statusListValidator, new RevocationController().createStatusList) - app.get('/revocation/statusList2021/list', RevocationController.didValidator, new RevocationController().fetchStatusList) + //credential-status + app.post('/credential-status/statusList2021/create', RevocationController.didValidator, RevocationController.statusListValidator, new RevocationController().createStatusList) + app.get('/credential-status/statusList2021/list', RevocationController.didValidator, new RevocationController().fetchStatusList) // store app.post(`/store`, new StoreController().set) app.get(`/store/:id`, new StoreController().get) diff --git a/src/controllers/user_info.ts b/src/controllers/user_info.ts index 90b8c386..7306f0e7 100644 --- a/src/controllers/user_info.ts +++ b/src/controllers/user_info.ts @@ -1,4 +1,4 @@ -import { Request, Response, NextFunction, json } from 'express' +import { Request, Response } from 'express' import * as dotenv from 'dotenv' dotenv.config() diff --git a/src/middleware/auth/account_auth.ts b/src/middleware/auth/account_auth.ts new file mode 100644 index 00000000..e137e71b --- /dev/null +++ b/src/middleware/auth/account_auth.ts @@ -0,0 +1,19 @@ +import { Request, Response } from "express"; +import { AbstractAuthHandler } from "./base_auth.js"; +import { IAuthResponse } from "../../types/authentication.js"; + +export class AccountAuthHandler extends AbstractAuthHandler { + + constructor () { + super() + this.registerRoute('/account', 'GET', 'read:account') + this.registerRoute('/account', 'POST', 'create:account') + } + public async handle(request: Request, response: Response): Promise { + if (!request.path.includes('/account')) { + return super.handle(request, response) + } + return this.commonPermissionCheck(request) + } + +} \ No newline at end of file diff --git a/src/middleware/auth/base_auth.ts b/src/middleware/auth/base_auth.ts new file mode 100644 index 00000000..7ab9c846 --- /dev/null +++ b/src/middleware/auth/base_auth.ts @@ -0,0 +1,303 @@ +import { Request, Response } from "express"; +import * as dotenv from 'dotenv' +import { createRemoteJWKSet, jwtVerify } from 'jose' +import stringify from 'json-stringify-safe' +import { cheqdDidRegex } from '../../types/types.js' +import { MethodToScope, IAuthResourceHandler, Namespaces, IAuthResponse } from '../../types/authentication.js' +import { IncomingHttpHeaders } from "http"; +import { LogToHelper } from "./logto.js"; + +dotenv.config() + +const { + LOGTO_ENDPOINT, + LOGTO_DEFAULT_RESOURCE_URL, +} = process.env + +// Constants +const OIDC_ISSUER = LOGTO_ENDPOINT + '/oidc' +const OIDC_JWKS_ENDPOINT = LOGTO_ENDPOINT + '/oidc/jwks' +const bearerTokenIdentifier = 'Bearer' + + +export abstract class AbstractAuthHandler implements IAuthResourceHandler +{ + private nextHandler: IAuthResourceHandler + private namespace: Namespaces + private token: string + private scopes: string[] | unknown + private logToHelper?: LogToHelper + + public customer_id: string + + private routeToScoupe: MethodToScope[] = [] + private static pathSkip = ['/swagger', '/user', '/static', '/logto'] + // private static regExpSkip = new RegExp("^/.*js") + + constructor () { + this.nextHandler = {} as IAuthResourceHandler + this.namespace = '' as Namespaces + this.token = '' as string + this.scopes = undefined + this.customer_id = '' as string + if (process.env.ENABLE_AUTHENTICATION) { + this.logToHelper = new LogToHelper() + } + } + + public async commonPermissionCheck(request: Request): Promise { + // Tries to get token from the request and other preps + const _setup = this.setupAuth(request) + if (_setup) { + return _setup + } + + const resourceAPI = AbstractAuthHandler.buildResourceAPIUrl(request) + if (!resourceAPI) { + return { + status: 500, + error: `Internal error. Issue with building resource API for the path ${request.path}`, + data: { + customerId: '', + scopes: [], + namespace: this.getNamespace(), + } + + } + } + // Verifies token for the resource API + const _resp = await this.verifyJWTToken(this.getToken(), resourceAPI) + if (_resp && _resp.status !== 200) { + return _resp + } + + // Checks if the token has the required scopes + if (!this.areValidScopes(request.path, request.method, this.getScopes() as string[], this.getNamespace())) { + return { + status: 400, + error: `Unauthorized error: Current LogTo account does not have the required scopes. + You need ${this.getScopeForRoute(request.path, request.method, this.getNamespace())} scope(s).`, + data: { + customerId: '', + scopes: [], + namespace: this.getNamespace(), + } + + } + } + return { + status: 200, + error: '', + data: { + customerId: this.getCustomerId(), + scopes: this.getScopes() as string[], + namespace: this.getNamespace(), + } + } + } + + // interface implementation + public setNext(handler: IAuthResourceHandler): IAuthResourceHandler { + this.nextHandler = handler; + return handler + } + + public async handle(request: Request, response: Response): Promise { + if (Object.keys(this.nextHandler).length !== 0) { + return this.nextHandler.handle(request, response) + } + // If request.path was not registered in the routeToScope, then skip the auth check + return { + status: 200, + error: '', + data: { + customerId: '', + scopes: [], + namespace: this.getNamespace(), + } + } + } + + public skipPath(path: string): boolean { + for (const ps of AbstractAuthHandler.pathSkip) { + if (path === "/" || path.startsWith(ps)) { + return true + } + } + return false + } + + // Verifies the JWT token for resourceAPI + public async verifyJWTToken(token: string, resourceAPI: string): Promise { + try { + const { payload } = await jwtVerify( + token, // The raw Bearer Token extracted from the request header + createRemoteJWKSet(new URL(OIDC_JWKS_ENDPOINT)), // generate a jwks using jwks_uri inquired from Logto server + { + // expected issuer of the token, should be issued by the Logto server + issuer: OIDC_ISSUER, + // expected audience token, should be the resource indicator of the current API + audience: resourceAPI, + } + ) + // Setup the scopes from the token + if (!payload.scope) { + return { + status: 400, + error: `Unauthorized error: No scope found in the token.`, + data: { + customerId: '', + scopes: [], + namespace: this.namespace + } + + } + } + this.scopes = (payload.scope as string).split(' ') + this.customer_id = payload.sub as string + + } catch (error) { + return { + status: 400, + error: `Unauthorized error: ${error}`, + data: { + customerId: '', + scopes: [], + namespace: this.namespace + } + + } + } + } + + // Make all the possible preps for the auth handler + public setupAuth(request: Request): IAuthResponse | void { + // setting up namespace. It should be testnet or mainnet + this.namespace = AbstractAuthHandler.getNamespaceFromRequest(request) + + // getting the accessToken from the request + // Firstly try to get it from the headers + let token: string = AbstractAuthHandler.extractBearerTokenFromHeaders(request.headers) as string + if (!token) { + // Otherwise try to get it from the user structure in the request + token = Object.getOwnPropertyNames(request.user).length > 0 ? request.user.accessToken as string : ""; + if (!token) { + return { + status: 401, + error: `Unauthorized error: Looks like you are not logged in using LogTo properly.`, + data: { + customerId: '', + scopes: [], + namespace: this.namespace + } + + } + } + } + this.token = token + } + + // common utils + public static getNamespaceFromRequest(req: Request): Namespaces { + const matches = stringify(req.body).match(cheqdDidRegex) + if (matches && matches.length > 0) { + if (Namespaces.Mainnet === matches[0]) { + return Namespaces.Mainnet + } + } + return Namespaces.Testnet + } + + public static buildResourceAPIUrl(request: Request): string { + const api_root = request.path.split('/')[1] + if (!api_root) { + // Skip if no api root + return "" + } + return `${LOGTO_DEFAULT_RESOURCE_URL}/${api_root}` + } + + public static extractBearerTokenFromHeaders({ authorization }: IncomingHttpHeaders): string | unknown { + + if (authorization && authorization.startsWith(bearerTokenIdentifier)) { + return authorization.slice(bearerTokenIdentifier.length + 1) + } + + return undefined + } + + // Getters + public getNamespace(): Namespaces { + return this.namespace + } + + public getToken(): string { + return this.token + } + + public getScopes(): string[] | unknown { + return this.scopes + } + + public getCustomerId(): string { + return this.customer_id + } + + public getAllLogToScopes(): string[] | void { + if (this.logToHelper) { + return this.logToHelper.getAllScopes() + } + } + + public getDefaultLogToScopes(): string[] | void { + if (this.logToHelper) { + return this.logToHelper.getDefaultScopes() + } + } + + public getAllLogToResources(): string[] | void { + if (this.logToHelper) { + return this.logToHelper.getAllResourcesWithNames() + } + } + + // Route and scope related funcs + public registerRoute(route: string, method: string, scope: string): void { + this.routeToScoupe.push(new MethodToScope(route, method, scope)) + } + + private findRule(route: string, method: string, namespace=Namespaces.Testnet): MethodToScope | null { + for (const item of this.routeToScoupe) { + if (item.isRule(route, method, namespace)) { + return item + } + } + return null + } + + public getScopeForRoute(route: string, method: string, namespace=Namespaces.Testnet): string | null { + const rule = this.findRule(route, method, namespace) + if (rule) { + return rule.getScope() + } + return null + } + + public isValidScope(route: string, method: string, scope: string, namespace=Namespaces.Testnet): boolean { + const rule = this.findRule(route, method, namespace) + if (rule) { + return rule.validate(route, method, scope, namespace) + } + // If no rule for route, then allow + return true + } + + public areValidScopes(route: string, method: string, scopes: string[], namespace=Namespaces.Testnet): boolean { + for (const scope of scopes) { + if (this.isValidScope(route, method, scope, namespace)) { + return true + } + } + return false + } +} \ No newline at end of file diff --git a/src/middleware/auth/credential-status.ts b/src/middleware/auth/credential-status.ts new file mode 100644 index 00000000..cee67570 --- /dev/null +++ b/src/middleware/auth/credential-status.ts @@ -0,0 +1,21 @@ +import { Request, Response } from "express"; +import { AbstractAuthHandler } from "./base_auth.js"; +import { IAuthResponse } from "../../types/authentication.js"; + +export class CredentialStatusAuthHandler extends AbstractAuthHandler { + + constructor () { + super() + this.registerRoute('/credential-status', 'POST', 'create:credential-status:statuslist2021:testnet') + this.registerRoute('/credential-status', 'POST', 'create:credential-status:statuslist2021:mainnet') + this.registerRoute('/credential-status/list', 'GET', 'list:credential-status:statuslist2021:testnet') + this.registerRoute('/credential-status/list', 'GET', 'list:credential-status:statuslist2021:mainnet') + } + public async handle(request: Request, response: Response): Promise { + if (!request.path.includes('/list')) { + return super.handle(request, response) + } + return this.commonPermissionCheck(request) + } + +} \ No newline at end of file diff --git a/src/middleware/auth/credential_auth.ts b/src/middleware/auth/credential_auth.ts new file mode 100644 index 00000000..f4d3a1a0 --- /dev/null +++ b/src/middleware/auth/credential_auth.ts @@ -0,0 +1,28 @@ +import { Request, Response } from "express"; +import { AbstractAuthHandler } from "./base_auth.js"; +import { IAuthResponse } from "../../types/authentication.js"; + +export class CredentialAuthHandler extends AbstractAuthHandler { + + constructor () { + super() + this.registerRoute('/credential/issue', 'POST', 'issue:credential:testnet') + this.registerRoute('/credential/issue', 'POST', 'issue:credential:mainnet') + this.registerRoute('/credential/verify', 'POST', 'verify:credential:testnet') + this.registerRoute('/credential/verify', 'POST', 'verify:credential:mainnet') + this.registerRoute('/credential/revoke', 'POST', 'revoke:credential:testnet') + this.registerRoute('/credential/revoke', 'POST', 'revoke:credential:mainnet') + this.registerRoute('/credential/suspend', 'POST', 'suspend:credential:testnet') + this.registerRoute('/credential/suspend', 'POST', 'suspend:credential:mainnet') + this.registerRoute('/credential/unsuspend', 'POST', 'unsuspend:credential:testnet') + this.registerRoute('/credential/unsuspend', 'POST', 'unsuspend:credential:mainnet') + } + + public async handle(request: Request, response: Response): Promise{ + if (!request.path.includes('/credential')) { + return super.handle(request, response) + } + return this.commonPermissionCheck(request) + } + +} \ No newline at end of file diff --git a/src/middleware/auth/did_auth.ts b/src/middleware/auth/did_auth.ts new file mode 100644 index 00000000..15c402e7 --- /dev/null +++ b/src/middleware/auth/did_auth.ts @@ -0,0 +1,27 @@ +import { Request, Response } from "express"; +import { AbstractAuthHandler } from "./base_auth.js"; +import { IAuthResponse } from "../../types/authentication.js"; + +export class DidAuthHandler extends AbstractAuthHandler { + + constructor () { + super() + this.registerRoute('/did/create', 'POST', 'create:did:testnet') + this.registerRoute('/did/create', 'POST', 'create:did:mainnet') + this.registerRoute('/did/list', 'GET', 'list:did:testnet') + this.registerRoute('/did/list', 'GET', 'list:did:mainnet') + this.registerRoute('/did', 'GET', 'read:did:testnet') + this.registerRoute('/did', 'GET', 'read:did:mainnet') + this.registerRoute('/did/update', 'POST', 'update:did:testnet') + this.registerRoute('/did/update', 'POST', 'update:did:mainnet') + + } + + public async handle(request: Request, response: Response): Promise { + if (!request.path.includes('/did')) { + return super.handle(request, response) + } + return this.commonPermissionCheck(request) + } + +} \ No newline at end of file diff --git a/src/middleware/auth/key_auth.ts b/src/middleware/auth/key_auth.ts new file mode 100644 index 00000000..60a50214 --- /dev/null +++ b/src/middleware/auth/key_auth.ts @@ -0,0 +1,22 @@ +import { Request, Response } from "express"; +import { AbstractAuthHandler } from "./base_auth.js"; +import { IAuthResponse } from "../../types/authentication.js"; + +export class KeyAuthHandler extends AbstractAuthHandler { + + constructor () { + super() + this.registerRoute('/key', 'POST', 'create:key') + this.registerRoute('/key', 'GET', 'read:key') + this.registerRoute('/key/list', 'GET', 'list:key') + } + + public async handle(request: Request, response: Response): Promise { + if (!request.path.includes('/key')) { + return super.handle(request, response) + } + return this.commonPermissionCheck(request) + + } + +} \ No newline at end of file diff --git a/src/middleware/auth/logto.ts b/src/middleware/auth/logto.ts new file mode 100644 index 00000000..ccdc2c4a --- /dev/null +++ b/src/middleware/auth/logto.ts @@ -0,0 +1,204 @@ +import * as dotenv from 'dotenv' +import { ILogToErrorResponse } from '../../types/authentication' +dotenv.config() + + +export class LogToHelper { + + private m2mToken: string + private allScopes: string[] + private allResourceWithNames: string[] + public defaultScopes: string[] + + constructor () { + this.m2mToken = "" + this.allScopes = [] + this.defaultScopes = [] + this.allResourceWithNames = [] + this.setup() + } + + public async setup(): Promise{ + let _r = {} as ILogToErrorResponse | void + _r = await this.setM2MToken() + if (_r && _r.status !== 200) { + return _r + } + _r = await this.setDefaultScopes() + if (_r && _r.status !== 200) { + return _r + } + _r = await this.setAllScopes() + if (_r && _r.status !== 200) { + return _r + } + _r = await this.setAllResourcesWithNames() + if (_r && _r.status !== 200) { + return _r + } + } + + public getAllScopes(): string[] { + return this.allScopes + } + + public getDefaultScopes(): string[] { + return this.defaultScopes + } + + public getAllResourcesWithNames(): string[] { + return this.allResourceWithNames + } + + private async setDefaultScopes(): Promise{ + const _r = await this.getAllResources() + if (_r.status === 200) { + for (const r of _r.data) { + if (r.indicator === process.env.LOGTO_DEFAULT_RESOURCE_URL) { + const _rr = await this.askForScopes(r.id) + if (_rr.status === 200) { + this.defaultScopes = _rr.data + } + else { + return _rr + } + } + } + } else { + return _r + } + } + + private async setM2MToken() : Promise { + const searchParams = new URLSearchParams({ + grant_type: 'client_credentials', + resource: process.env.LOGTO_MANAGEMENT_API as string, + scope: 'all', + }); + + const uri = new URL('/oidc/token', process.env.LOGTO_ENDPOINT); + const token = `Basic ${btoa(process.env.LOGTO_M2M_APP_ID + ':' + process.env.LOGTO_M2M_APP_SECRET)}`; + + try { + const response = await fetch(uri, { + method: 'POST', + body: searchParams, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: token, + }, + }); + const data = await response.json(); + if (response.status === 200) { + this.m2mToken = data.access_token + } + } catch (err) { + return { + status: 500, + error: `setM2MToken: ${err}`, + data: {} + } + } + } + + private async setAllScopes(): Promise { + + const allResources = await this.getAllResources() + if (allResources.status === 200) { + for (const resource of allResources.data) { + if (resource.id !== "management-api") { + const scopes = await this.askForScopes(resource.id) + if (scopes.status == 200) { + this.allScopes = this.allScopes.concat(scopes.data) + } + } + } + + } else { + return allResources + } + } + + private async setAllResourcesWithNames(): Promise { + const allResources = await this.getAllResources() + if (allResources.status === 200) { + for (const resource of allResources.data) { + this.allResourceWithNames.push(resource.indicator) + } + } else { + return allResources + } + } + + private async askForScopes(resourceId: string): Promise { + const uri = new URL(`/api/resources/${resourceId}/scopes`, process.env.LOGTO_ENDPOINT); + const scopes = [] + + try { + const response = await fetch(uri, { + headers: { + Authorization: 'Bearer ' + this.m2mToken, + }, + }); + + if (response.status === 200) { + + const metadata = await response.json() + for (const sc of metadata) { + scopes.push(sc.name) + } + return { + status: 200, + error: "", + data: scopes + } + } + return { + error: await response.text(), + status: response.status, + data: {} + } + + + } catch (err) { + return { + error: `askForScopes ${err}`, + status: 500, + data: {} + } + } + } + + private async getAllResources(): Promise { + const uri = new URL(`/api/resources`, process.env.LOGTO_ENDPOINT); + + try { + const response = await fetch(uri, { + headers: { + Authorization: 'Bearer ' + this.m2mToken, + }, + }); + + if (response.status === 200) { + const metadata = (await response.json()) + return { + error: "", + status: 200, + data: metadata, + } + } + return { + error: await response.text(), + status: response.status, + data: {} + } + + } catch (err) { + return { + error: `getAllResources ${err}`, + status: 500, + data: {}, + } + } + } +} \ No newline at end of file diff --git a/src/middleware/authentication.ts b/src/middleware/authentication.ts index 4da966e1..299c3d0a 100644 --- a/src/middleware/authentication.ts +++ b/src/middleware/authentication.ts @@ -1,38 +1,40 @@ import { Request, Response, NextFunction } from 'express' -import { createRemoteJWKSet, jwtVerify } from 'jose' import { CustomerService } from '../services/customer.js' -import { IncomingHttpHeaders } from 'http' import * as dotenv from 'dotenv' -import {apiGuarding} from "../types/types.js" +import { withLogto, handleAuthRoutes } from '@logto/express' +import { configLogToExpress } from '../types/constants.js' +import { AccountAuthHandler } from './auth/account_auth.js' +import { CredentialAuthHandler } from './auth/credential_auth.js' +import { DidAuthHandler } from './auth/did_auth.js' +import { KeyAuthHandler } from './auth/key_auth.js' +import { CredentialStatusAuthHandler } from './auth/credential-status.js' +import { AbstractAuthHandler } from './auth/base_auth.js' + dotenv.config() const { - LOGTO_ENDPOINT, - LOGTO_RESOURCE_URL, ENABLE_AUTHENTICATION, DEFAULT_CUSTOMER_ID, ENABLE_EXTERNAL_DB } = process.env -const OIDC_ISSUER = LOGTO_ENDPOINT + '/oidc' -const OIDC_JWKS_ENDPOINT = LOGTO_ENDPOINT + '/oidc/jwks' -const bearerTokenIdentifier = 'Bearer' - -export const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) => { - if (!authorization) { - throw new Error('Authorization header is missing.') - } - if (!authorization.startsWith(bearerTokenIdentifier)) { - throw new Error(`Authorization token type is not supported. Valid type: "${bearerTokenIdentifier}".`) - } - - return authorization.slice(bearerTokenIdentifier.length + 1) -} +const authHandler = new AccountAuthHandler() +authHandler.setNext(new CredentialAuthHandler()). +setNext(new DidAuthHandler()). +setNext(new KeyAuthHandler()). +setNext(new CredentialStatusAuthHandler()) export class Authentication { + static wrapperHandleAuthRoutes(request: Request, response: Response, next: NextFunction) { + return handleAuthRoutes( + {...configLogToExpress, + scopes: authHandler.getAllLogToScopes() as string[], + resources: authHandler.getAllLogToResources() as string[]})(request, response, next) + } + static handleError(error: Error, request: Request, response: Response, next: NextFunction) { if (error) { return response.status(401).send({ @@ -45,7 +47,7 @@ export class Authentication { static async accessControl(request: Request, response: Response, next: NextFunction) { let message = undefined - if (apiGuarding.skipPath(request.path)) + if (authHandler.skipPath(request.path)) return next() switch(ENABLE_EXTERNAL_DB) { @@ -71,41 +73,19 @@ export class Authentication { static async guard(jwtRequest: Request, response: Response, next: NextFunction) { const { provider } = jwtRequest.body as { claim: string, provider: string } - if (apiGuarding.skipPath(jwtRequest.path)) + // const namespace = apiGuarding.getNamespaceFromRequest(jwtRequest) + if (authHandler.skipPath(jwtRequest.path)) return next() try { if (ENABLE_AUTHENTICATION === 'true') { - const token = extractBearerTokenFromHeaders(jwtRequest.headers) - - const { payload } = await jwtVerify( - token, // The raw Bearer Token extracted from the request header - createRemoteJWKSet(new URL(OIDC_JWKS_ENDPOINT)), // generate a jwks using jwks_uri inquired from Logto server - { - // expected issuer of the token, should be issued by the Logto server - issuer: OIDC_ISSUER, - // expected audience token, should be the resource indicator of the current API - audience: LOGTO_RESOURCE_URL, - } - ) - - let scopes: string[] = [] - if (payload.scope) { - scopes = (payload.scope as string).split(' ') - } else { - return response.status(400).json({ - error: `Unauthorized error: It's required to provide a token with scopes inside.` - }) + // If response got back that means error was raised + const _resp = await authHandler.handle(jwtRequest, response) + if (_resp && _resp.status !== 200) { + return response.status(_resp.status).json({ + error: _resp.error}) } - - if (!apiGuarding.areValidScopes(jwtRequest.path, jwtRequest.method, scopes)) { - return response.status(400).json({ - error: `Unauthorized error: Provided token does not have the required scopes. You need ${apiGuarding.getScopeForRoute(jwtRequest.path, jwtRequest.method)} scope(s).` - }) - } - - // custom payload logic - response.locals.customerId = payload.sub + response.locals.customerId = _resp.data.customerId } else if (DEFAULT_CUSTOMER_ID) { response.locals.customerId = DEFAULT_CUSTOMER_ID } else { @@ -123,4 +103,22 @@ export class Authentication { }) } } + + static async withLogtoWrapper(request: Request, response: Response, next: NextFunction) { + if (authHandler.skipPath(request.path)) + return next() + try { + const resourceAPI = AbstractAuthHandler.buildResourceAPIUrl(request) + if (!resourceAPI) { + return next() + } + return withLogto({...configLogToExpress, resource: resourceAPI, scopes: authHandler.getAllLogToScopes() as string[]})(request, response, next) + } catch (err) { + return response.status(500).send({ + authenticated: false, + error: `${err}`, + customerId: null, + }) + } + } } \ No newline at end of file diff --git a/src/static/custom-button.ts b/src/static/custom-button.ts index 9f2f1729..d645d07a 100644 --- a/src/static/custom-button.ts +++ b/src/static/custom-button.ts @@ -8,13 +8,6 @@ window.addEventListener("load", function () { window.location.href = base_url + '/logto/sign-in'; }; - const user_button: HTMLButtonElement = document.createElement('button'); - user_button.innerHTML = 'User info'; - user_button.classList.add('btn', 'authorize'); - user_button.onclick = function () { - window.location.href = base_url + '/user'; - }; - const logout_button: HTMLButtonElement = document.createElement('button'); logout_button.innerHTML = 'Log out'; logout_button.classList.add('btn', 'authorize'); @@ -24,7 +17,6 @@ window.addEventListener("load", function () { const auth_pan: Element = document.getElementsByClassName('auth-wrapper')[0]; auth_pan.appendChild(login_button); - auth_pan.appendChild(user_button); auth_pan.appendChild(logout_button); }); \ No newline at end of file diff --git a/src/static/swagger.json b/src/static/swagger.json index 984c2a26..632a04b4 100644 --- a/src/static/swagger.json +++ b/src/static/swagger.json @@ -530,7 +530,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RevocationResult" + "$ref": "#/components/schemas/credentialStatusResult" } } } @@ -882,7 +882,7 @@ } } }, - "/revocation/statusList2021/create": { + "/credential-status/statusList2021/create": { "post": { "tags": [ "Revocation" @@ -960,7 +960,7 @@ } } }, - "/revocation/statusList2021/list": { + "/credential-status/statusList2021/list": { "get": { "tags": [ "Revocation" @@ -1566,7 +1566,7 @@ "type": "TextDocument" } }, - "RevocationResult": { + "credentialStatusResult": { "properties": { "revoked": { "type": "boolean" diff --git a/src/types/authentication.ts b/src/types/authentication.ts new file mode 100644 index 00000000..a4e53723 --- /dev/null +++ b/src/types/authentication.ts @@ -0,0 +1,57 @@ +import {Request, Response } from 'express' + +export enum Namespaces{ + Testnet = 'testnet', + Mainnet = 'mainnet', +} + +export class MethodToScope { + private route: string + private method: string + private scope: string + constructor(route: string, method: string, scope: string) { + this.route = route + this.method = method + this.scope = scope + } + + public validate(route: string, method: string, scope: string, namespace=Namespaces.Testnet): boolean { + return this.route === route && this.method === method && this.scope === scope && this.scope.includes(namespace) + } + + public isRule(route: string, method: string, namespace=Namespaces.Testnet): boolean { + return this.route === route && this.method === method && this.scope.includes(namespace) + } + + public getScope(): string { + return this.scope + } + } + +export interface IAuthResponse{ + status: number, + data: { + customerId: string, + scopes: string[], + namespace: Namespaces, + } + error: string +} + +export interface ILogToErrorResponse { + status: number, + error: string, + data: any +} + +export interface IAuthResourceHandler { + setNext(handler: IAuthResourceHandler): IAuthResourceHandler + handle(request: Request, response: Response): Promise + skipPath(path: string): boolean + + // Getters + getNamespace(): string + getScopes(): string[] | unknown + getCustomerId(): string + getToken(): string +} \ No newline at end of file diff --git a/src/types/constants.ts b/src/types/constants.ts index 20377274..7ad3c8ba 100644 --- a/src/types/constants.ts +++ b/src/types/constants.ts @@ -2,7 +2,12 @@ import { ReservedScope, UserScope } from '@logto/express' import * as dotenv from 'dotenv' dotenv.config() -const {ALL_SCOPES, LOGTO_ENDPOINT, LOGTO_RESOURCE_URL, LOGTO_APP_ID, LOGTO_APP_SECRET, APPLICATION_BASE_URL} = process.env +const {ALL_API_RESOURCES, + LOGTO_ENDPOINT, + LOGTO_DEFAULT_RESOURCE_URL, + LOGTO_APP_ID, + LOGTO_APP_SECRET, + APPLICATION_BASE_URL} = process.env export const HEADERS = { @@ -25,15 +30,11 @@ export const VERIDA_APP_NAME = 'Cheqd Verida Connector' export const VERIDA_CREDENTIAL_RECORD_SCHEMA = 'https://common.schemas.verida.io/credential/base/v0.2.0/schema.json' -const all_scopes: string[] = ALL_SCOPES ? ALL_SCOPES.split(' ') || [] : [] // Map for path and required user scope for that action export const configLogToExpress = { endpoint: LOGTO_ENDPOINT, appId: LOGTO_APP_ID, appSecret: LOGTO_APP_SECRET, baseUrl: APPLICATION_BASE_URL, - resources: [LOGTO_RESOURCE_URL], // You may need to replace it with your app's production address - resource: LOGTO_RESOURCE_URL, - scopes: [ReservedScope.OpenId, ReservedScope.OfflineAccess, UserScope.Identities, ...all_scopes], getAccessToken: true, } \ No newline at end of file diff --git a/src/types/environment.d.ts b/src/types/environment.d.ts index 28d59a20..60c69f09 100644 --- a/src/types/environment.d.ts +++ b/src/types/environment.d.ts @@ -24,12 +24,15 @@ declare global { LOGTO_ENDPOINT: string LOGTO_APP_ID: string LOGTO_APP_SECRET: string - LOGTO_RESOURCE_URL: string + LOGTO_DEFAULT_RESOURCE_URL: string + LOGTO_M2M_APP_ID: string + LOGTO_M2M_APP_SECRET: string + LOGTO_MANAGEMENT_API: string + ALL_API_RESOURCES: string // Authentication ENABLE_AUTHENTICATION: string | "false" DEFAULT_CUSTOMER_ID: string | undefined - ALL_SCOPES: string COOKIE_SECRET: string // Verida diff --git a/src/types/types.ts b/src/types/types.ts index 8eb71940..b878501c 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -6,8 +6,7 @@ import { ICredentialIssuer, ICredentialVerifier, W3CVerifiableCredential, - TAgent, - CredentialStatusReference + TAgent } from '@veramo/core' import { ICheqd, ICheqdStatusList2021Options } from '@cheqd/did-provider-cheqd/build/types/agent/ICheqd' import { ICredentialIssuerLD } from '@veramo/credential-ld' @@ -93,88 +92,7 @@ export type SpecValidationResult = { error?: string } -class MethodToScope { - private route: string - private method: string - private scope: string - constructor(route: string, method: string, scope: string) { - this.route = route - this.method = method - this.scope = scope - } - - public validate(route: string, method: string, scope: string): boolean { - return this.route === route && this.method === method && this.scope === scope - } - - public isRule(route: string, method: string): boolean { - return this.route === route && this.method === method - } - - public getScope(): string { - return this.scope - } -} - -export class ApiGuarding { - private routeToScoupe: MethodToScope[] = [] - private static pathSkip = ['/', '/swagger', '/user', '/static/custom-button.js'] - private static regExpSkip = new RegExp("^/.*js") - constructor() { - this.registerRoute('/account', 'GET', 'account:read') - this.registerRoute('/account', 'POST', 'account:create') - this.registerRoute('/key', 'POST', 'key:create') - this.registerRoute('/key', 'GET', 'key:read') - this.registerRoute('/credential/issue', 'POST', 'credential:issue') - this.registerRoute('/credential/verify', 'POST', 'credential:verify') - this.registerRoute('/did/create', 'POST', 'did:create') - } - - private registerRoute(route: string, method: string, scope: string): void { - this.routeToScoupe.push(new MethodToScope(route, method, scope)) - } - - private findRule(route: string, method: string): MethodToScope | null { - for (const item of this.routeToScoupe) { - if (item.isRule(route, method)) { - return item - } - } - return null - } - - public getScopeForRoute(route: string, method: string): string | null { - const rule = this.findRule(route, method) - if (rule) { - return rule.getScope() - } - return null - } - - public isValidScope(route: string, method: string, scope: string): boolean { - const rule = this.findRule(route, method) - if (rule) { - return rule.validate(route, method, scope) - } - // If no rule for route, then allow - return true - } - - public areValidScopes(route: string, method: string, scopes: string[]): boolean { - for (const scope of scopes) { - if (this.isValidScope(route, method, scope)) { - return true - } - } - return false - } - - public skipPath(path: string): boolean { - return ApiGuarding.pathSkip.includes(path) || path.match(ApiGuarding.regExpSkip) !== null - } -} -export const apiGuarding = new ApiGuarding() export type VeramoAgent = TAgent