Feat/oauth2 (#6281): Added oauth to API paths

* Added oauth provider.

* Fixed provider.

* Added auth flow.

* Fixed auth flow and added scaffolding vite config.

* Added working oauth2.

* Fixed dockerfile.

* Adapted run.sh script

* Moved api tests to oauth2.

* Updated security schemes.

* Removed api key from existance.

* Fixed installation

* Added missing issuer in config.

* Fixed dev dependencies.

* Updated lock file.
This commit is contained in:
SamTV12345 2024-03-26 17:11:24 +01:00 committed by GitHub
parent 562177022f
commit fb56809e55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
44 changed files with 1782 additions and 237 deletions

View file

@ -21,30 +21,12 @@
import {MapArrayType} from "../types/MapType";
const absolutePaths = require('../utils/AbsolutePaths');
import fs from 'fs';
const api = require('../db/API');
import log4js from 'log4js';
const padManager = require('../db/PadManager');
const randomString = require('../utils/randomstring');
const argv = require('../utils/Cli').argv;
import createHTTPError from 'http-errors';
const apiHandlerLogger = log4js.getLogger('APIHandler');
// ensure we have an apikey
let apikey:string|null = null;
const apikeyFilename = absolutePaths.makeAbsolute(argv.apikey || './APIKEY.txt');
try {
apikey = fs.readFileSync(apikeyFilename, 'utf8');
apiHandlerLogger.info(`Api key file read from: "${apikeyFilename}"`);
} catch (e) {
apiHandlerLogger.info(
`Api key file "${apikeyFilename}" not found. Creating with random contents.`);
apikey = randomString(32);
fs.writeFileSync(apikeyFilename, apikey!, 'utf8');
}
import {Http2ServerRequest, Http2ServerResponse} from "node:http2";
import {publicKeyExported} from "../security/OAuth2Provider";
import {jwtVerify} from "jose";
// a list of all functions
const version:MapArrayType<any> = {};
@ -167,21 +149,20 @@ exports.version = version;
type APIFields = {
apikey: string;
api_key: string;
padID: string;
padName: string;
}
/**
* Handles a HTTP API call
* Handles an HTTP API call
* @param {String} apiVersion the version of the api
* @param {String} functionName the name of the called function
* @param fields the params of the called function
* @req express request object
* @res express response object
* @param req express request object
* @param res express response object
*/
exports.handle = async function (apiVersion: string, functionName: string, fields: APIFields) {
exports.handle = async function (apiVersion: string, functionName: string, fields: APIFields, req: Http2ServerRequest, res: Http2ServerResponse) {
// say goodbye if this is an unknown API version
if (!(apiVersion in version)) {
throw new createHTTPError.NotFound('no such api version');
@ -192,13 +173,20 @@ exports.handle = async function (apiVersion: string, functionName: string, field
throw new createHTTPError.NotFound('no such function');
}
// check the api key!
fields.apikey = fields.apikey || fields.api_key;
if (fields.apikey !== apikey!.trim()) {
if(!req.headers.authorization) {
throw new createHTTPError.Unauthorized('no or wrong API Key');
}
try {
await jwtVerify(req.headers.authorization!.replace("Bearer ", ""), publicKeyExported!, {algorithms: ['RS256'],
requiredClaims: ["admin"]})
} catch (e) {
throw new createHTTPError.Unauthorized('no or wrong API Key');
}
// sanitize any padIDs before continuing
if (fields.padID) {
fields.padID = await padManager.sanitizePadId(fields.padID);
@ -217,7 +205,3 @@ exports.handle = async function (apiVersion: string, functionName: string, field
// call the api function
return api[functionName].apply(this, functionParams);
};
exports.exportedForTestingOnly = {
apiKey: apikey,
};

View file

@ -483,14 +483,24 @@ const generateDefinitionForVersion = (version:string, style = APIPathStyle.FLAT)
...defaultResponses,
},
securitySchemes: {
ApiKey: {
type: 'apiKey',
in: 'query',
name: 'apikey',
openid: {
type: "oauth2",
flows: {
authorizationCode: {
authorizationUrl: settings.sso.issuer+"/oidc/auth",
tokenUrl: settings.sso.issuer+"/oidc/token",
scopes: {
openid: "openid",
profile: "profile",
email: "email",
admin: "admin"
}
}
},
},
},
},
security: [{ApiKey: []}],
security: [{openid: []}],
};
// build operations
@ -657,7 +667,7 @@ exports.expressPreSession = async (hookName:string, {app}:any) => {
}
// start and bind to express
api.init();
await api.init();
app.use(apiRoot, async (req:any, res:any) => {
let response = null;
try {

View file

@ -0,0 +1,274 @@
import {ArgsExpressType} from "../types/ArgsExpressType";
import Provider, {Account, Configuration} from 'oidc-provider';
import {generateKeyPair, exportJWK, KeyLike} from 'jose'
import MemoryAdapter from "./OIDCAdapter";
import path from "path";
const settings = require('../utils/Settings');
import {IncomingForm} from 'formidable'
import express, {Request, Response} from 'express';
import {format} from 'url'
import {ParsedUrlQuery} from "node:querystring";
import {Http2ServerRequest, Http2ServerResponse} from "node:http2";
const configuration: Configuration = {
scopes: ['openid', 'profile', 'email'],
findAccount: async (ctx, id) => {
const users = settings.users as {
[username: string]: {
password: string;
is_admin: boolean;
}
}
const usersArray1 = Object.keys(users).map((username) => ({
username,
...users[username]
}));
const account = usersArray1.find((user) => user.username === id);
if(account === undefined) {
return undefined
}
if (account.is_admin) {
return {
accountId: id,
claims: () => ({
sub: id,
admin: true
})
} as Account
} else {
return {
accountId: id,
claims: () => ({
sub: id,
})
} as Account
}
},
ttl:{
AccessToken: 1 * 60 * 60, // 1 hour in seconds
AuthorizationCode: 10 * 60, // 10 minutes in seconds
ClientCredentials: 1 * 60 * 60, // 1 hour in seconds
IdToken: 1 * 60 * 60, // 1 hour in seconds
RefreshToken: 1 * 24 * 60 * 60, // 1 day in seconds
},
claims: {
openid: ['sub'],
email: ['email'],
profile: ['name'],
admin: ['admin']
},
cookies: {
keys: ['oidc'],
},
features:{
devInteractions: {enabled: false},
},
adapter: MemoryAdapter
};
export let publicKeyExported: KeyLike|null
export let privateKeyExported: KeyLike|null
/*
This function is used to initialize the OAuth2 provider
*/
export const expressCreateServer = async (hookName: string, args: ArgsExpressType, cb: Function) => {
const {privateKey, publicKey} = await generateKeyPair('RS256');
const privateKeyJWK = await exportJWK(privateKey);
publicKeyExported = publicKey
privateKeyExported = privateKey
const oidc = new Provider(settings.sso.issuer, {
...configuration, jwks: {
keys: [
privateKeyJWK
],
},
conformIdTokenClaims: false,
claims: {
address: ['address'],
email: ['email', 'email_verified'],
phone: ['phone_number', 'phone_number_verified'],
profile: ['birthdate', 'family_name', 'gender', 'given_name', 'locale', 'middle_name', 'name',
'nickname', 'picture', 'preferred_username', 'profile', 'updated_at', 'website', 'zoneinfo'],
},
features:{
userinfo: {enabled: true},
claimsParameter: {enabled: true},
devInteractions: {enabled: false},
resourceIndicators: {enabled: true, defaultResource(ctx) {
return ctx.origin;
},
getResourceServerInfo(ctx, resourceIndicator, client) {
return {
scope: client.scope as string,
audience: 'account',
accessTokenFormat: 'jwt',
};
},
useGrantedResource(ctx, model) {
return true;
},},
jwtResponseModes: {enabled: true},
},
clientBasedCORS: (ctx, origin, client) => {
return true
},
extraTokenClaims: async (ctx, token) => {
if(token.kind === 'AccessToken') {
// Add your custom claims here. For example:
const users = settings.users as {
[username: string]: {
password: string;
is_admin: boolean;
}
}
const usersArray1 = Object.keys(users).map((username) => ({
username,
...users[username]
}));
const account = usersArray1.find((user) => user.username === token.accountId);
return {
admin: account?.is_admin
};
}
},
clients: settings.sso.clients
});
args.app.post('/interaction/:uid', async (req: Http2ServerRequest, res: Http2ServerResponse, next:Function) => {
const formid = new IncomingForm();
try {
// @ts-ignore
const {login, password} = (await formid.parse(req))[0]
const {prompt, jti, session,cid, params, grantId} = await oidc.interactionDetails(req, res);
const client = await oidc.Client.find(params.client_id as string);
switch (prompt.name) {
case 'login': {
const users = settings.users as {
[username: string]: {
password: string;
admin: boolean;
}
}
const usersArray1 = Object.keys(users).map((username) => ({
username,
...users[username]
}));
const account = usersArray1.find((user) => user.username === login as unknown as string && user.password === password as unknown as string);
if (!account) {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({error: "Invalid login"}));
}
if (account) {
await oidc.interactionFinished(req, res, {
login: {accountId: account.username}
}, {mergeWithLastSubmission: false});
}
break;
}
case 'consent': {
let grant;
if (grantId) {
// we'll be modifying existing grant in existing session
grant = await oidc.Grant.find(grantId);
} else {
// we're establishing a new grant
grant = new oidc.Grant({
accountId: session!.accountId,
clientId: params.client_id as string,
});
}
if (prompt.details.missingOIDCScope) {
// @ts-ignore
grant!.addOIDCScope(prompt.details.missingOIDCScope.join(' '));
}
if (prompt.details.missingOIDCClaims) {
grant!.addOIDCClaims(prompt.details.missingOIDCClaims as string[]);
}
if (prompt.details.missingResourceScopes) {
for (const [indicator, scope] of Object.entries(prompt.details.missingResourceScopes)) {
grant!.addResourceScope(indicator, scope.join(' '));
}
}
const result = {consent: {grantId: await grant!.save()}};
await oidc.interactionFinished(req, res, result, {
mergeWithLastSubmission: true,
});
break;
}
}
await next();
} catch (err:any) {
return res.writeHead(500).end(err.message);
}
})
args.app.get('/interaction/:uid', async (req: Request, res: Response, next: Function) => {
try {
const {
uid, prompt, params, session,
} = await oidc.interactionDetails(req, res);
params["state"] = uid
switch (prompt.name) {
case 'login': {
res.redirect(format({
pathname: '/views/login.html',
query: params as ParsedUrlQuery
}))
break
}
case 'consent': {
res.redirect(format({
pathname: '/views/consent.html',
query: params as ParsedUrlQuery
}))
break
}
default:
return res.sendFile(path.join(settings.root,'src','static', 'oidc','login.html'));
}
} catch (err) {
return next(err);
}
});
args.app.use('/views/', express.static(path.join(settings.root,'src','static', 'oidc'), {maxAge: 1000 * 60 * 60 * 24}));
/*
oidc.on('authorization.error', (ctx, error) => {
console.log('authorization.error', error);
})
oidc.on('server_error', (ctx, error) => {
console.log('server_error', error);
})
oidc.on('grant.error', (ctx, error) => {
console.log('grant.error', error);
})
oidc.on('introspection.error', (ctx, error) => {
console.log('introspection.error', error);
})
oidc.on('revocation.error', (ctx, error) => {
console.log('revocation.error', error);
})*/
args.app.use("/oidc", oidc.callback());
//cb();
}

View file

@ -0,0 +1,5 @@
export type OAuth2User = {
username: string;
password: string;
admin: boolean;
}

View file

@ -0,0 +1,115 @@
import {LRUCache} from 'lru-cache';
import type {Adapter, AdapterPayload} from "oidc-provider";
const options = {
max: 500,
sizeCalculation: (item:any, key:any) => {
return 1
},
// for use with tracking overall storage size
maxSize: 5000,
// how long to live in ms
ttl: 1000 * 60 * 5,
// return stale items before removing from cache?
allowStale: false,
updateAgeOnGet: false,
updateAgeOnHas: false,
}
const epochTime = (date = Date.now()) => Math.floor(date / 1000);
const storage = new LRUCache<string,AdapterPayload|string[]|string>(options);
function grantKeyFor(id: string) {
return `grant:${id}`;
}
function userCodeKeyFor(userCode:string) {
return `userCode:${userCode}`;
}
class MemoryAdapter implements Adapter{
private readonly name: string;
constructor(name:string) {
this.name = name;
}
key(id:string) {
return `${this.name}:${id}`;
}
destroy(id:string) {
const key = this.key(id);
const found = storage.get(key) as AdapterPayload;
const grantId = found && found.grantId;
storage.delete(key);
if (grantId) {
const grantKey = grantKeyFor(grantId);
(storage.get(grantKey) as string[])!.forEach(token => storage.delete(token));
storage.delete(grantKey);
}
return Promise.resolve();
}
consume(id: string) {
(storage.get(this.key(id)) as AdapterPayload)!.consumed = epochTime();
return Promise.resolve();
}
find(id: string): Promise<AdapterPayload | void | undefined> {
if (storage.has(this.key(id))){
return Promise.resolve<AdapterPayload>(storage.get(this.key(id)) as AdapterPayload);
}
return Promise.resolve<undefined>(undefined)
}
findByUserCode(userCode: string) {
const id = storage.get(userCodeKeyFor(userCode)) as string;
return this.find(id);
}
upsert(id: string, payload: {
iat: number;
exp: number;
uid: string;
kind: string;
jti: string;
accountId: string;
loginTs: number;
}, expiresIn: number) {
const key = this.key(id);
storage.set(key, payload, {ttl: expiresIn * 1000});
return Promise.resolve();
}
findByUid(uid: string): Promise<AdapterPayload | void | undefined> {
for(const [_, value] of storage.entries()){
if(typeof value ==="object" && "uid" in value && value.uid === uid){
return Promise.resolve(value);
}
}
return Promise.resolve(undefined);
}
revokeByGrantId(grantId: string): Promise<void | undefined> {
const grantKey = grantKeyFor(grantId);
const grant = storage.get(grantKey) as string[];
if (grant) {
grant.forEach((token) => storage.delete(token));
storage.delete(grantKey);
}
return Promise.resolve();
}
}
export default MemoryAdapter

View file

@ -45,10 +45,5 @@ for (let i = 0; i < argv.length; i++) {
exports.argv.sessionkey = arg;
}
// Override location of APIKEY.txt file
if (prevArg === '--apikey') {
exports.argv.apikey = arg;
}
prevArg = arg;
}

View file

@ -342,6 +342,11 @@ exports.requireAuthentication = false;
exports.requireAuthorization = false;
exports.users = {};
/*
* This setting is used for configuring sso
*/
exports.sso = {}
/*
* Show settings in admin page, by default it is true
*/