Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: Github auth flow in API #540

Draft
wants to merge 87 commits into
base: staging
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
87 commits
Select commit Hold shift + click to select a range
3d60ea0
feat: Add Swagger/OpenAPI documentation with Swagger UI and Scalar AP…
irony Dec 10, 2024
41227bd
chore: Update dependencies and package configuration
irony Dec 10, 2024
aa19f58
feat: Add ESM module support and exports configuration
irony Dec 10, 2024
3eb62cc
refactor: Remove Swagger and Scalar API documentation dependencies
irony Dec 10, 2024
da1e902
feat: Add Scalar API docs and expose OpenAPI JSON endpoint
irony Dec 10, 2024
125ee93
feat: Update API documentation setup with @scalar/express-api-reference
irony Dec 10, 2024
b25dc0f
feat: Integrate zod-to-openapi for OpenAPI schema generation
irony Dec 10, 2024
89c59a0
feat: add @asteasolutions/zod-to-openapi
irony Dec 10, 2024
edb97da
Merge branch 'staging' into feat/scalar
irony Dec 10, 2024
39d41ac
fix: Add `.openapi()` to Zod schemas for OpenAPI registration
irony Dec 10, 2024
327be03
fix: Remove unnecessary `.openapi()` calls in OpenAPI schema registra…
irony Dec 10, 2024
116ab19
fix: Add OpenAPI schema registration support with extendZodWithOpenApi
irony Dec 10, 2024
5c0580c
feat: Centralize OpenAPI schemas with Zod extensions
irony Dec 10, 2024
e4011f0
fix: Add missing CompanyInputSchema to openapi schemas
irony Dec 10, 2024
c3ba029
fix: Add missing ErrorSchema to schemas and update imports
irony Dec 10, 2024
9aba56a
fix: Resolve ErrorSchemaBase import in registry.ts
irony Dec 10, 2024
323e991
feat: Update OpenAPI schemas to match API output structure
irony Dec 10, 2024
70767e0
docs: Update /companies endpoint documentation to match full response…
irony Dec 10, 2024
1c40cbe
refactor: Use registered Goal and Initiative schemas in routes and re…
irony Dec 10, 2024
e7212d6
feat: Register Industry schema and add Swagger documentation
irony Dec 10, 2024
bdc6003
refactor: Consolidate schemas and remove code duplication
irony Dec 10, 2024
cd76147
refactor: Centralize configuration and improve middleware error handling
irony Dec 10, 2024
5401e19
refactor: Use apiConfig for CORS and auth token handling
irony Dec 10, 2024
94b66c2
fix: Import missing IndustrySchema in registry file
irony Dec 10, 2024
a4a24b6
refactor: Move API constants from constants to config file
irony Dec 10, 2024
39dbd16
refactor: Consolidate API config constants into default export object
irony Dec 10, 2024
6e4ec08
feat: Allow CACHE_MAX_AGE to be configured via environment variable
irony Dec 10, 2024
3887d6a
refactor: Convert API config exports to camelCase
irony Dec 10, 2024
02bcdd3
refactor: Remove companySchemas and update imports to use openapi/sch…
irony Dec 10, 2024
e577f38
refactor: Update Swagger industry schema reference to use IndustrySchema
irony Dec 10, 2024
4a41a15
refactor: Remove unused HTTP_METHODS import from middlewares
irony Dec 10, 2024
aa5ca6a
fix: remove unused files
irony Dec 10, 2024
7bf065b
refactor: Replace inline industry schema with $ref in readCompanies r…
irony Dec 10, 2024
991a365
fix: Remove duplicate type definitions in readCompanies route
irony Dec 10, 2024
ca74a7b
docs: Add Swagger documentation for company goals and initiatives upd…
irony Dec 10, 2024
1e37703
refactor: Move update methods to separate files for better modularity
irony Dec 10, 2024
a8e124a
refactor: Modularize updateCompanies route with separate route handlers
irony Dec 10, 2024
e7e38e7
refactor: Rename files to follow consistent [entity].[method].ts pattern
irony Dec 10, 2024
9bfb7f4
docs: Add JSDoc documentation for reporting periods route handler
irony Dec 10, 2024
b5a76f2
refactor: Rename company-related files to use singular form
irony Dec 10, 2024
ecfe04f
chore: rename for better readability
irony Dec 10, 2024
3619e28
fix: Add missing imports and resolve syntax errors in company update …
irony Dec 10, 2024
e534148
fix: Add missing imports and router setup in company update route
irony Dec 10, 2024
71d560c
refactor: Remove duplicate router and consolidate imports in company …
irony Dec 10, 2024
b38d569
refactor: Simplify company update route and remove duplicate code
irony Dec 10, 2024
e950d78
refactor: Simplify company update route to core upsert functionality
irony Dec 10, 2024
6523a2e
refactor: Remove duplicate route handlers from company.update.ts
irony Dec 10, 2024
6287c38
fix: Resolve multiple default export issue in company.update.ts
irony Dec 10, 2024
4ec3cb9
docs: Add comprehensive Swagger documentation to company routes
irony Dec 10, 2024
edec75f
feat: Update swagger configuration to register all endpoints
irony Dec 10, 2024
ed19e55
feat: Add Klimatkollen logo to API documentation
irony Dec 10, 2024
3084cf3
docs: Add logo, getting started, and API key instructions to document…
irony Dec 10, 2024
66edcb6
refactor: Remove hardcoded configuration from Swagger UI setup
irony Dec 10, 2024
e769a42
refactor: Change API docs route from '/docs' to '/'
irony Dec 10, 2024
c10953d
feat: Add redirect from root route to /api
irony Dec 10, 2024
c83c0ee
chore: Add ts-jest as dev dependency for testing
irony Dec 10, 2024
c5ea03a
test: Add basic API integration test suite for companies, goals, init…
irony Dec 10, 2024
5c8b6b1
fix: resolve issues from PR
irony Dec 10, 2024
6572866
feat: Improve error handling with detailed validation and user-friend…
irony Dec 12, 2024
e6a5df8
Merge branch 'staging' into feat/scalar
irony Dec 12, 2024
acbeb39
fix: Improve error handling for company data loading in Prisma
irony Dec 12, 2024
bc29520
fix: Remove duplicate OpenAPI annotations and fix YAML indentation
irony Dec 12, 2024
c4c66f0
fix: Correct YAML indentation in company read route documentation
irony Dec 12, 2024
0c9b652
fix: Handle optional metadata in company read route
irony Dec 12, 2024
dbe18fb
fix: Update metadata schema to handle array and nullable types
irony Dec 12, 2024
6837e0b
refactor: Remove unnecessary parameter validation for GET company route
irony Dec 12, 2024
38ef131
fix: Update metadata schema and Prisma query for company read endpoint
irony Dec 12, 2024
17a6fa0
Merge branch 'staging' into feat/scalar
irony Dec 18, 2024
6131f6e
feat: Implement GitHub OAuth authentication with JWT tokens
irony Jan 8, 2025
a9a1080
feat: Add configurable GitHub OAuth callback URL from environment
irony Jan 8, 2025
0899d81
feat: Add GitHub organization membership validation for update routes
irony Jan 8, 2025
44ca00c
feat: Add bearer authentication configuration to Swagger UI
irony Jan 8, 2025
bd59e3f
docs: Add authentication section to Swagger documentation
irony Jan 8, 2025
eb860e1
refactor: Simplify dotenv import and update default organization name
irony Jan 8, 2025
f150501
refactor: Align auth config with zod validation and consistent defaults
irony Jan 8, 2025
7abb473
chore: Update dependencies and add new packages for authentication an…
irony Jan 8, 2025
b0b2949
chore: Add @types/express-session dev dependency
irony Jan 8, 2025
b7386b3
feat: Add GitHub OAuth authentication endpoints to OpenAPI documentation
irony Jan 8, 2025
430a81b
feat: Enhance GitHub OAuth flow with session handling and improved re…
irony Jan 8, 2025
54fb75c
fix: Add authentication and org membership checks to company goals ro…
irony Jan 8, 2025
7f43fcd
feat: Add authentication middleware bypass for auth routes
irony Jan 8, 2025
39f4c40
refactor: Remove premature authentication middleware from auth routes
irony Jan 8, 2025
2bb01bd
fix: Add GitHub token to auth flow and org membership check
irony Jan 8, 2025
b57c043
feat: Add githubId and githubToken fields to User model in Prisma schema
irony Jan 8, 2025
4d05026
fix: Update GitHub auth route to correctly handle OAuth redirect
irony Jan 8, 2025
edfa8e3
fix: Modify GitHub OAuth route to manually construct redirect URL
irony Jan 8, 2025
a3f84be
fix: migrations
irony Jan 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10,190 changes: 7,479 additions & 2,711 deletions package-lock.json

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"version": "3.4.27",
"description": "",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"engines": {
"node": ">=22.0.0"
},
Expand All @@ -23,19 +26,26 @@
"author": "Christian Landgren, William Ryder, Samuel Plumppu mfl",
"license": "MIT License",
"dependencies": {
"@asteasolutions/zod-to-openapi": "^7.3.0",
"@bull-board/api": "^6.5.3",
"@bull-board/express": "^6.5.3",
"@prisma/client": "^5.22.0",
"@types/axios": "^0.9.36",
"@types/node": "^22.10.0",
"axios": "^1.7.9",
"bullmq": "^5.28.0",
"chromadb": "^1.9.4",
"compromise": "^14.13.0",
"cors": "^2.8.5",
"discord.js": "^14.16.3",
"dotenv": "^16.4.5",
"express": "^5.0.1",
"express-session": "^1.18.1",
"jest": "^29.7.0",
"jsonwebtoken": "^9.0.2",
"openai": "^4.73.1",
"passport": "^0.7.0",
"passport-github2": "^0.1.12",
"pdf2pic": "^3.1.3",
"pino-http": "^10.3.0",
"prisma-zod-generator": "^0.8.13",
Expand All @@ -45,16 +55,22 @@
"zod": "^3.23.8"
},
"devDependencies": {
"@scalar/express-api-reference": "^0.2.75",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/express-session": "^1.17.10",
"@types/node": "^22.8.4",
"@types/passport": "^1.0.17",
"concurrently": "^9.1.0",
"deepl-node": "^1.15.0",
"exceljs": "^4.4.0",
"jest": "^29.7.0",
"pino-pretty": "^13.0.0",
"prisma": "^5.22.0",
"supertest": "^7.0.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"ts-jest": "^29.2.5",
"typescript": "^5.7.2"
},
"overrides": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "githubId" TEXT,
ADD COLUMN "githubToken" TEXT;
10 changes: 5 additions & 5 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -357,12 +357,12 @@ model Metadata {
}

model User {
id Int @id @default(autoincrement())
email String @unique
name String
id Int @id @default(autoincrement())
email String @unique
name String
githubId String?
githubToken String?

// TODO: connect with github ID
// TODO: store github profile image - or get it via API
updated Metadata[] @relation("metadata_user_id")
verified Metadata[] @relation("metadata_verified_by")
}
30 changes: 28 additions & 2 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import express from 'express'
import pino from 'pino-http'
import readCompanies from './routes/readCompanies'
import updateCompanies from './routes/updateCompanies'
import swaggerJsdoc from 'swagger-jsdoc'
import { apiReference } from '@scalar/express-api-reference'
import readCompanies from './routes/companies/company.read'
import updateCompanies from './routes/companies/company.update'
import { errorHandler } from './routes/middlewares'
import { swaggerOptions } from './swagger'
import auth from './routes/auth'

const apiRouter = express.Router()
const pinoConfig = process.stdin.isTTY && {
Expand All @@ -12,9 +16,31 @@ const pinoConfig = process.stdin.isTTY && {
level: 'info',
}
apiRouter.use(pino(pinoConfig || undefined))

// API Routes
// Generate OpenAPI spec
const openApiSpec = swaggerJsdoc(swaggerOptions)

apiRouter.use('/auth', auth)

// API Routes
apiRouter.use('/companies', readCompanies)
apiRouter.use('/companies', updateCompanies)

// API Documentation
apiRouter.get('/openapi.json', (req, res) => {
res.json(openApiSpec)
})

apiRouter.use(
'/',
apiReference({
spec: {
url: '/api/openapi.json',
},
})
)

// TODO: Why does this error handler not capture errors thrown in readCompanies?
apiRouter.use(errorHandler)

Expand Down
22 changes: 22 additions & 0 deletions src/config/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,35 @@ const envSchema = z.object({
),
API_BASE_URL: z.string().default('http://localhost:3000/api'),
PORT: z.coerce.number().default(3000),
CACHE_MAX_AGE: z.coerce.number().default(3000),
})

const env = envSchema.parse(process.env)

export type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
const ONE_DAY = 1000 * 60 * 60 * 24

export default {
cacheMaxAge: env.CACHE_MAX_AGE,

authorizedUsers: {
garbo: '[email protected]',
alex: '[email protected]',
} as const,

developmentOrigins: ['http://localhost:4321'],
productionOrigins: [
'https://beta.klimatkollen.se',
'https://klimatkollen.se',
],

httpMethods: {
get: 'GET',
post: 'POST',
patch: 'PATCH',
put: 'PUT',
delete: 'DELETE',
} as const,
tokens: env.API_TOKENS,
frontendURL: env.FRONTEND_URL,
baseURL: env.API_BASE_URL,
Expand Down
31 changes: 31 additions & 0 deletions src/config/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import 'dotenv/config'
import { z } from 'zod'

const envSchema = z.object({
GITHUB_CLIENT_ID: z.string(),
GITHUB_CLIENT_SECRET: z.string(),
GITHUB_CALLBACK_URL: z
.string()
.default('http://localhost:3000/api/auth/github/callback'),
GITHUB_ORG_NAME: z.string().default('Klimatbyran'),
JWT_SECRET: z.string().default('your-secret-key'),
SESSION_SECRET: z.string().default('session-secret'),
})

const env = envSchema.parse(process.env)

export default {
github: {
clientID: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
callbackURL: env.GITHUB_CALLBACK_URL,
organization: env.GITHUB_ORG_NAME,
},
jwt: {
secret: env.JWT_SECRET,
expiresIn: '24h',
},
session: {
secret: env.SESSION_SECRET,
},
}
17 changes: 17 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import express from 'express'
import { parseArgs } from 'node:util'
import session from 'express-session'
import passport from 'passport'

import api from './api'
import apiConfig from './config/api'
import authConfig from './config/auth'
import './lib/auth' // Initialize passport

const { values } = parseArgs({
options: {
Expand All @@ -18,9 +22,22 @@ const START_BOARD = !values['api-only']
const port = apiConfig.port
const app = express()

app.use(session({
secret: authConfig.session.secret,
resave: false,
saveUninitialized: false
}))

app.use(passport.initialize())
app.use(passport.session())

app.get('/favicon.ico', express.static('public/favicon.png'))
app.use('/api', api)

app.get('/', (req, res) => {
res.redirect('/api')
})

if (START_BOARD) {
const queue = (await import('./queue')).default
app.use('/admin/queues', queue)
Expand Down
130 changes: 130 additions & 0 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import passport from 'passport'
import { Strategy as GitHubStrategy } from 'passport-github2'
import jwt from 'jsonwebtoken'
import { prisma } from './prisma'
import authConfig from '../config/auth'
import { Request, Response, NextFunction } from 'express'
import { GarboAPIError } from './garbo-api-error'
import axios from 'axios'
import { User } from '@prisma/client'

passport.use(
new GitHubStrategy(
authConfig.github,
async (accessToken, refreshToken, profile, done) => {
try {
const user = await prisma.user.upsert({
where: { email: profile.emails![0].value },
update: {
name: profile.displayName,
githubId: profile.id,
githubToken: accessToken,
},
create: {
email: profile.emails![0].value,
name: profile.displayName,
githubId: profile.id,
githubToken: accessToken,
},
})
return done(null, user)
} catch (error) {
return done(error)
}
}
)
)

passport.serializeUser((user: any, done) => {
done(null, user.id)
})

passport.deserializeUser(async (id: number, done) => {
try {
const user = await prisma.user.findUnique({ where: { id } })
done(null, user)
} catch (error) {
done(error)
}
})

export const generateToken = (user: any) => {
return jwt.sign(
{
id: user.id,
email: user.email,
githubId: user.githubId,
githubToken: user.githubToken,
},
authConfig.jwt.secret,
{
expiresIn: authConfig.jwt.expiresIn,
}
)
}

export const authenticateJWT = (
req: Request,
res: Response,
next: NextFunction
) => {
const authHeader = req.headers.authorization

if (!authHeader) {
throw new GarboAPIError('No authorization header', { statusCode: 401 })
}

const token = authHeader.split(' ')[1]

try {
const user = jwt.verify(token, authConfig.jwt.secret)
res.locals.user = user
next()
} catch (error) {
throw new GarboAPIError('Invalid token', { statusCode: 401 })
}
}

export const checkOrgMembership = async (
req: Request,
res: Response,
next: NextFunction
) => {
const user = res.locals.user as User

if (!user) {
throw new GarboAPIError('User not authenticated', { statusCode: 401 })
}

try {
const response = await axios.get(
`https://api.github.com/orgs/${authConfig.github.organization}/members/${user.githubId}`,
{
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${user.githubToken}`,
},
}
)

if (response.status === 204) {
next()
} else {
throw new GarboAPIError(
'User is not a member of the required organization',
{ statusCode: 403 }
)
}
} catch (error) {
if (error.response?.status === 404) {
throw new GarboAPIError(
'User is not a member of the required organization',
{ statusCode: 403 }
)
}
throw new GarboAPIError('Failed to verify organization membership', {
statusCode: 500,
original: error,
})
}
}
Loading