Added SSO based auth

This commit is contained in:
Jyotirmoy Bandyopadhayaya 2023-04-20 13:38:40 +05:30
parent 2e4af394f9
commit edb2544e60
Signed by: bravo68web
GPG Key ID: F5671FD7BCB9917A
29 changed files with 314 additions and 35 deletions

3
.gitignore vendored
View File

@ -129,3 +129,6 @@ dist
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
**/jwtRS256.key
**/jwtRS256.key.pub

View File

@ -0,0 +1,25 @@
import { authClient } from "../helpers/auth_client";
import { generators } from 'openid-client';
import { configKeys } from "..";
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
export default () => {
const authurl = authClient.authorizationUrl({
scope: 'email profile openid',
code_challenge,
code_challenge_method: 'S256',
client_id: configKeys.KEYCLOAK_CLIENT_ID,
redirect_uri: configKeys.KEYCLOAK_REDIRECT_URI,
});
return {
authurl,
};
}
export {
code_challenge,
code_verifier,
}

View File

@ -0,0 +1,32 @@
import { NextFunction, Response } from "express";
import {authClient} from "../helpers/auth_client";
import { ModRequest } from "../types";
import { CustomError } from "../libs/error";
const middleware = async (req: ModRequest | any, res: Response, next: NextFunction) => {
try {
const authHeader = req.headers?.authorization?.split(' ');
if (!authHeader) {
throw new Error("No authorization header");
}
const token : string = authHeader[1];
const user = await authClient.userinfo(token, {
method: 'GET',
tokenType: 'Bearer',
params: {
access_token: token
},
via: 'header'
});
req.user = user;
next();
}
catch (err) {
next(new CustomError({
message: "Invalid token",
statusCode: 401
}))
}
}
export default middleware

View File

@ -0,0 +1,10 @@
import {authClient} from "../helpers/auth_client";
import { code_verifier } from './check'
export default async (session_state: string, code: string) => {
return authClient.callback(
'http://localhost:4038/auth/signin/callback',
{ code_verifier, code, session_state, expires_in: "1d" },
{ code_verifier }
);
}

View File

@ -1,9 +1,13 @@
import fs from 'fs'
import { parse as parseFile } from 'envfile'
import { Issuer } from 'openid-client';
const keyCloakIssuer : Issuer = await Issuer.discover(process.env.KEYCLOAK_AUTH_SERVER_URL!);
console.log('🔐 Connected to Keycloak');
type IconfigStore = 'development' | 'production'
interface IConfigKeys {
export interface IConfigKeys {
PORT: string | number
NODE_ENV: string
HASURA_GRAPHQL_ADMIN_SECRET: string
@ -32,6 +36,13 @@ interface IConfigKeys {
AWS_ACCESS_KEY_ID: string
AWS_SECRET_ACCESS_KEY: string
AWS_REGION: string
PUBLIC_KEY: string
PRIVATE_KEY: string
KEYCLOAK_ISSUER: Issuer
KEYCLOAK_CLIENT_ID: string
KEYCLOAK_CLIENT_SECRET: string
KEYCLOAK_REDIRECT_URI: string
KEYCLOAK_AUTH_SERVER_URL: string
}
export default class ConfigStoreFactory {
@ -46,9 +57,14 @@ export default class ConfigStoreFactory {
}
public async getConfigStore() {
const publicKEY = fs.readFileSync('./jwtRS256.key', 'utf8');
const privateKEY = fs.readFileSync('./jwtRS256.key.pub', 'utf8');
if (this.configStoreType === 'development') {
const envContent = await fs.readFileSync(`./.env`, 'utf8')
const env: Partial<IConfigKeys> = await parseFile(envContent)
env.PUBLIC_KEY = publicKEY
env.PRIVATE_KEY = privateKEY
env.KEYCLOAK_ISSUER = keyCloakIssuer
return env
} else {
let reqEnvContent: any = await fs.readFileSync(
@ -59,6 +75,9 @@ export default class ConfigStoreFactory {
reqEnvContent = reqEnvContent.split('\n')
let missingKeys: string[] = []
let env: Partial<IConfigKeys> = {}
env.PUBLIC_KEY = publicKEY
env.PRIVATE_KEY = privateKEY
env.KEYCLOAK_ISSUER = keyCloakIssuer
for (const line of reqEnvContent) {
if (!process.env[line]) {
missingKeys.push(line)
@ -67,6 +86,7 @@ export default class ConfigStoreFactory {
if (missingKeys.length > 0) {
throw new Error(`Missing keys: ${missingKeys}`)
}
return env
}
}

View File

@ -0,0 +1,21 @@
import { Request, Response } from 'express'
import { makeResponse } from '../libs'
import check from '../auth/check'
import verify from '../auth/verify'
import { ModRequest } from '../types'
export default class AuthController {
public signin = (req: Request, res: Response) => {
const { authurl } = check()
res.redirect(authurl)
}
public callback = async (req: Request, res: Response) => {
const { session_state, code } = req.query as { session_state: string, code: string}
res.send(makeResponse(await verify(session_state, code)))
}
public me = (req: ModRequest, res: Response) => {
res.send(makeResponse(req.user))
}
}

View File

@ -1,6 +1,6 @@
actions: []
custom_types:
enums: []
input_objects: []
objects: []
scalars: []
enums: []
input_objects: []
objects: []
scalars: []

View File

@ -0,0 +1 @@
{}

View File

@ -1 +1,9 @@
[]
- name: default
kind: postgres
configuration:
connection_info:
database_url:
from_env: PG_DATABASE_URL
isolation_level: read-committed
use_prepared_statements: false
tables: "!include default/tables/tables.yaml"

View File

@ -0,0 +1 @@
disabled_for_roles: []

View File

@ -0,0 +1 @@
[]

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1 @@
[]

View File

@ -0,0 +1,19 @@
import { configKeys } from "..";
const { KEYCLOAK_ISSUER } = configKeys
const authConfig = {
client_id: configKeys.KEYCLOAK_CLIENT_ID,
'auth-server-url': configKeys.KEYCLOAK_AUTH_SERVER_URL,
'ssl-required': 'all',
resource: configKeys.KEYCLOAK_CLIENT_ID,
credentials: { 'secret-jwt': { secret: configKeys.KEYCLOAK_CLIENT_SECRET } },
'confidential-port': 0,
redirect_uri: configKeys.KEYCLOAK_REDIRECT_URI,
client_secret: configKeys.KEYCLOAK_CLIENT_SECRET,
default_max_age: 3600000,
}
const authClient = new KEYCLOAK_ISSUER.Client(authConfig)
export { authClient }

View File

@ -43,7 +43,7 @@ export default class CacheClient {
}
this._nodeClient = new NodeCache()
console.log(`Caching Client initialized in '${env}' environment`)
console.log(`🍞 Caching Client initialized in '${env}' environment`)
}
static async set(key: string, value: any) {

View File

@ -5,45 +5,40 @@ import morgan from 'morgan'
import helmet from 'helmet'
import { hgqlInit } from './helpers'
import cacheClient from './helpers/cache.factory';
import routes from './routes'
import { errorHandler, notFoundHandler } from './libs'
import pkg from './package.json' assert { type: 'json' }
import configStore from './configs'
import configStore, { IConfigKeys } from './configs';
export const app: express.Application = express()
hgqlInit()
console.log('🚀', '@b68/api', 'v' + pkg.version)
hgqlInit()
cacheClient.init();
const isDev: boolean = process.env.NODE_ENV == 'production'
console.log(isDev ? '🚀 Production Mode' : '🚀 Development Mode')
console.log(isDev ? '🚀 Production Mode' : '👷 Development Mode')
const configs = new configStore(isDev)
const configKeys: any = await configs.getConfigStore()
const configKeys: IConfigKeys = await configs.getConfigStore() as IConfigKeys
app.use(cors())
app.use(helmet())
app.use(morgan('dev'))
app.use(express.json())
app.use(express.urlencoded({ extended: true, limit: '50mb' }))
app.set('view engine', 'ejs');
app.use('/health', (req, res) => {
return res.status(200).json({
app: pkg.name,
request_ip: req.ip,
uptime: process.uptime(),
hrtime: process.hrtime(),
})
})
console.log('☄ ', 'Base Route', '/')
console.log('☄', 'Base Route', '/')
app.use('/', routes)
app.use(notFoundHandler)
app.use(errorHandler)
app.listen(process.env.PORT, async () => {
console.log(`\nServer running on port ${process.env.PORT}`)
app.listen(configKeys.PORT, async () => {
console.log(`\n🌈 Server running on port ${configKeys.PORT}`)
})
export { configKeys }

View File

@ -14,6 +14,7 @@
"axios": "^1.2.1",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"ejs": "^3.1.9",
"envfile": "^6.18.0",
"express": "^4.18.2",
"form-data": "^4.0.0",
@ -27,6 +28,7 @@
"napi-nanoid": "^0.0.4",
"node-cache": "^5.1.2",
"nodemailer": "^6.8.0",
"openid-client": "^5.4.0",
"osu-api-extended": "^2.5.12",
"redis": "^4.5.1",
"typescript": "^4.9.3"

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,18 +1,18 @@
import { Router } from 'express'
import { makeResponse } from '../libs'
import AuthController from '../controllers/auth.controller'
import middleware from '../auth/middleware'
const router = Router()
const authController = new AuthController()
router.get('/', (req, res) => {
res.send(makeResponse({ message: 'Hello World auth!' }))
})
router.get('/signin', authController.signin)
router.all('/err', async (req, res, next) => {
try {
throw new Error('This is an error')
} catch (err) {
next(err)
}
})
router.get('/signin/callback', authController.callback)
router.get('/me', middleware, authController.me as any)
router.get('/', function(req, res) {
res.render('pages/auth');
});
export default router

View File

@ -0,0 +1,16 @@
import { Router } from 'express'
const router = Router()
router.use('/health',
(req, res) => {
return res.status(200).json({
status: "OK",
app: "B68 API",
request_ip: req.ip,
uptime: process.uptime(),
hrtime: process.hrtime(),
})
})
export default router

View File

@ -30,7 +30,7 @@ const loadRoutes = async (dirPath: string, prefix = '/') => {
''
)
const modRoute = path.join(prefix, route)
console.log('🛰️', 'Loaded', modRoute)
console.log('🛰️ ', 'Loaded', modRoute)
const mod = await import(path.join(baseDir, prefix + f.name))
router.use(modRoute, mod.default)
@ -48,4 +48,13 @@ let baseDir = path.dirname(__filename)
baseDir = path.resolve(baseDir)
loadRoutes(baseDir)
router.get('/', function(req, res) {
res.render('pages/index');
});
router.get('/favicon.ico', function(req, res) {
res.sendFile(path.join(__dirname, '../public', 'favicon.ico'));
});
export default router

View File

@ -1,3 +1,5 @@
import { Request } from 'express'
export interface PaginationType {
page: number
limit: number
@ -5,3 +7,7 @@ export interface PaginationType {
sort_by?: string
filters?: { [k: string]: any }
}
export interface ModRequest extends Request {
user: any
}

View File

@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<%- include('../partials/head'); %>
</head>
<body class="container">
<header>
<%- include('../partials/header'); %>
</header>
<main>
<div class="jumbotron">
<a href="/auth/signin">
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#exampleModal">
Sign In
</button>
</a>
</div>
</main>
<footer>
<%- include('../partials/footer'); %>
</footer>
</body>
</html>

View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<%- include('../partials/head'); %>
</head>
<body class="container">
<header>
<%- include('../partials/header'); %>
</header>
<main>
<div class="jumbotron">
<h1>API Landing</h1>
<p>Welcome to B68 API Home</p>
</div>
</main>
<footer>
<%- include('../partials/footer'); %>
</footer>
</body>
</html>

View File

@ -0,0 +1 @@
<p class="text-center text-muted">&copy; Copyright 2023 <a href="https://itsmebravo.dev">Jyotirmoy Bandyopadhayaya</a> | <a href="https://github.com/bravo68web">Github</a></p>

View File

@ -0,0 +1,15 @@
<meta charset="UTF-8">
<title>B68 API</title>
<!-- CSS (load bootstrap from a CDN) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="icon" href="/favicon.ico">
<style>
body { padding-top:50px; }
.jumbotron {
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View File

@ -0,0 +1,8 @@
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="/">B68 API</a>
<ul class="navbar-nav mr-auto">
<li class="nav-item">
<a class="nav-link" href="/auth">Auth</a>
</li>
</ul>
</nav>

View File

@ -3754,6 +3754,13 @@ ejs@^3.1.7:
dependencies:
jake "^10.8.5"
ejs@^3.1.9:
version "3.1.9"
resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.9.tgz#03c9e8777fe12686a9effcef22303ca3d8eeb361"
integrity sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==
dependencies:
jake "^10.8.5"
emoji-regex@^8.0.0:
version "8.0.0"
resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz"
@ -5332,6 +5339,11 @@ joi@^17.7.0:
"@sideway/formula" "^3.0.0"
"@sideway/pinpoint" "^2.0.0"
jose@^4.10.0:
version "4.14.0"
resolved "https://registry.yarnpkg.com/jose/-/jose-4.14.0.tgz#c8c03579a0ba3598194c92ccca777d96adca8f48"
integrity sha512-LSA/XenLPwqk6e2L+PSUNuuY9G4NGsvjRWz6sJcUBmzTLEPJqQh46FHSUxnAQ64AWOkRO6bSXpy3yXuEKZkbIA==
js-sdsl@^4.1.4:
version "4.2.0"
resolved "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.2.0.tgz"
@ -6464,6 +6476,11 @@ object-assign@^4, object-assign@^4.1.1:
resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"
integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
object-hash@^2.0.1:
version "2.2.0"
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5"
integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==
object-inspect@^1.12.2, object-inspect@^1.9.0:
version "1.12.2"
resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz"
@ -6519,6 +6536,11 @@ object.values@^1.1.5, object.values@^1.1.6:
define-properties "^1.1.4"
es-abstract "^1.20.4"
oidc-token-hash@^5.0.1:
version "5.0.2"
resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-5.0.2.tgz#f9ca7f7f1f92d721a2973e66b7430cb52a486648"
integrity sha512-U91Ba78GtVBxcExLI7U+hC2AwJQqXQEW/D3fjmJC4hhSVIgdl954KO4Gu95WqAlgDKJdLATxkmuxraWLT0fVRQ==
on-finished@2.4.1:
version "2.4.1"
resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz"
@ -6568,6 +6590,16 @@ open@^8.4.0:
is-docker "^2.1.1"
is-wsl "^2.2.0"
openid-client@^5.4.0:
version "5.4.0"
resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-5.4.0.tgz#77f1cda14e2911446f16ea3f455fc7c405103eac"
integrity sha512-hgJa2aQKcM2hn3eyVtN12tEA45ECjTJPXCgUh5YzTzy9qwapCvmDTVPWOcWVL0d34zeQoQ/hbG9lJhl3AYxJlQ==
dependencies:
jose "^4.10.0"
lru-cache "^6.0.0"
object-hash "^2.0.1"
oidc-token-hash "^5.0.1"
optionator@^0.9.1:
version "0.9.1"
resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz"