Skip to content

Commit

Permalink
feat: implement GitHub identity provider
Browse files Browse the repository at this point in the history
  • Loading branch information
beeman committed Jan 7, 2024
1 parent 904efaf commit f83af5a
Show file tree
Hide file tree
Showing 24 changed files with 238 additions and 40 deletions.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ DATABASE_RESET=false
# Discord OAuth2 client ID and secret (required)
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
# GitHub OAuth2 client ID and secret (required)
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
# Enable GraphQL Playground
GRAPHQL_PLAYGROUND=true
# JWT Secret (generate a random string with `openssl rand -hex 32`)
Expand Down
1 change: 1 addition & 0 deletions api-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ input AdminUpdateUserInput {

type AppConfig {
authDiscordEnabled: Boolean!
authGithubEnabled: Boolean!
authPasswordEnabled: Boolean!
authRegisterEnabled: Boolean!
authSolanaEnabled: Boolean!
Expand Down
1 change: 1 addition & 0 deletions libs/api/auth/data-access/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from './lib/dto/login.input'
export * from './lib/dto/register.input'
export * from './lib/guards/api-anon-jwt-guard.service'
export * from './lib/guards/api-auth-discord.guard'
export * from './lib/guards/api-auth-github.guard'
export * from './lib/guards/api-auth-graphql-admin-guard'
export * from './lib/guards/api-auth-graphql-user-guard.service'
export * from './lib/guards/api-auth-jwt-guard.service'
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ApiAuthDiscordGuard } from './guards/api-auth-discord.guard'
import { ApiAuthGraphQLUserGuard } from './guards/api-auth-graphql-user-guard.service'
import { ApiAuthJwtStrategy } from './strategies/api-auth-jwt.strategy'
import { DiscordStrategy } from './strategies/discord.strategy'
import { GithubStrategy } from './strategies/github.strategy'

@Module({
imports: [
Expand All @@ -18,7 +19,14 @@ import { DiscordStrategy } from './strategies/discord.strategy'
}),
PassportModule,
],
providers: [ApiAuthDiscordGuard, ApiAuthGraphQLUserGuard, ApiAuthJwtStrategy, ApiAuthService, DiscordStrategy],
providers: [
ApiAuthDiscordGuard,
ApiAuthGraphQLUserGuard,
ApiAuthJwtStrategy,
ApiAuthService,
DiscordStrategy,
GithubStrategy,
],
exports: [ApiAuthService],
})
export class ApiAuthDataAccessModule {}
54 changes: 53 additions & 1 deletion libs/api/auth/data-access/src/lib/api-auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,60 @@ export class ApiAuthService {
return this.findUsername(newUsername)
}

async validateRequest({
req,
providerId,
provider,
profile,
accessToken,
refreshToken,
}: {
providerId: string
provider: IdentityProvider
accessToken: string
refreshToken: string
profile: Prisma.InputJsonValue
req: AuthRequest
}) {
const found = await this.findUserByIdentity({
provider,
providerId,
})

if (found && req.user?.id && found.ownerId !== req.user?.id) {
throw new Error(`This ${provider} account is already linked to another user.`)
}

if (found) {
await this.core.data.identity.update({
where: { id: found.id },
data: { accessToken, refreshToken, verified: true, profile },
})
return found.owner
}

const identity: Prisma.IdentityCreateWithoutOwnerInput = {
provider,
providerId,
accessToken,
refreshToken,
verified: true,
profile,
}

if (req.user?.id) {
return await this.updateUserWithIdentity(req.user.id, identity)
}

return await this.createUserWithIdentity(identity)
}

async createUserWithIdentity(identity: Prisma.IdentityCreateWithoutOwnerInput) {
const username = await this.findUsername((identity.profile as { username: string }).username ?? identity.providerId)
const admin = this.core.config.authDiscordAdminIds?.includes(identity.providerId)
const admin = this.core.config.isAdminId(identity.provider, identity.providerId)
this.logger.verbose(
`Creating user ${username} with identity ${identity.providerId} (${identity.provider}) (admin: ${admin})`,
)

const user = await this.core.data.user.create({
data: {
Expand All @@ -85,6 +136,7 @@ export class ApiAuthService {
role: admin ? UserRole.Admin : UserRole.User,
status: UserStatus.Active,
username,
name: (identity.profile as { name?: string })?.name,
identities: {
create: {
...identity,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'

@Injectable()
export class ApiAuthGithubGuard extends AuthGuard('github') {}
35 changes: 5 additions & 30 deletions libs/api/auth/data-access/src/lib/strategies/discord.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport'
import { IdentityProvider, Prisma } from '@prisma/client'
import { IdentityProvider } from '@prisma/client'
import { ApiCoreService } from '@pubkey-stack/api-core-data-access'

import { Profile, Strategy } from 'passport-discord'
Expand All @@ -20,39 +20,14 @@ export class DiscordStrategy extends PassportStrategy(Strategy, 'discord') {
}

async validate(req: AuthRequest, accessToken: string, refreshToken: string, profile: Profile) {
const found = await this.service.findUserByIdentity({
provider: this.provider,
return this.service.validateRequest({
req,
providerId: profile.id,
})

if (found && req.user?.id && found.ownerId !== req.user?.id) {
throw new Error('This Discord account is already linked to another user.')
}

const identityProfile = createDiscordProfile(profile)

if (found) {
await this.core.data.identity.update({
where: { id: found.id },
data: { accessToken, refreshToken, verified: true, profile: identityProfile },
})
return found.owner
}

const identity: Prisma.IdentityCreateWithoutOwnerInput = {
provider: this.provider,
providerId: profile.id,
accessToken,
refreshToken,
verified: true,
profile: identityProfile,
}

if (req.user?.id) {
return await this.service.updateUserWithIdentity(req.user.id, identity)
}

return await this.service.createUserWithIdentity(identity)
profile: createDiscordProfile(profile),
})
}
}

Expand Down
41 changes: 41 additions & 0 deletions libs/api/auth/data-access/src/lib/strategies/github.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport'
import { IdentityProvider } from '@prisma/client'
import { ApiCoreService } from '@pubkey-stack/api-core-data-access'

import { Profile, Strategy } from 'passport-github'
import { ApiAuthService, AuthRequest } from '../api-auth.service'

@Injectable()
export class GithubStrategy extends PassportStrategy(Strategy, 'github') {
private readonly provider = IdentityProvider.GitHub
constructor(private core: ApiCoreService, private service: ApiAuthService) {
super({
clientID: process.env['GITHUB_CLIENT_ID'],
clientSecret: process.env['GITHUB_CLIENT_SECRET'],
callbackURL: core.config.webUrl + '/api/auth/github/callback',
scope: ['public_profile'],
passReqToCallback: true,
})
}

async validate(req: AuthRequest, accessToken: string, refreshToken: string, profile: Profile) {
return this.service.validateRequest({
req,
providerId: profile.id,
provider: this.provider,
accessToken,
refreshToken,
profile: createGithubProfile(profile),
})
}
}

function createGithubProfile(profile: Profile) {
return {
externalId: profile.id,
username: profile.username,
avatarUrl: profile.photos?.[0].value,
name: profile.displayName,
}
}
21 changes: 20 additions & 1 deletion libs/api/auth/feature/src/lib/api-auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common'

import { ApiAnonJwtGuard, ApiAuthDiscordGuard, ApiAuthService, AuthRequest } from '@pubkey-stack/api-auth-data-access'
import {
ApiAnonJwtGuard,
ApiAuthDiscordGuard,
ApiAuthGithubGuard,
ApiAuthService,
AuthRequest,
} from '@pubkey-stack/api-auth-data-access'
import { Response } from 'express-serve-static-core'

@Controller('auth')
Expand All @@ -20,6 +26,19 @@ export class ApiAuthController {
res.redirect(this.service.core.config.webUrl + '/dashboard')
}

@Get('github')
@UseGuards(ApiAuthGithubGuard)
githubAuthLogin() {
// This method triggers the GitHub OAuth2 flow
}

@Get('github/callback')
@UseGuards(ApiAnonJwtGuard, ApiAuthGithubGuard)
async githubAuthCallback(@Req() req: AuthRequest, @Res({ passthrough: true }) res: Response) {
await this.service.setUserCookie({ req, res, user: req.user })
res.redirect(this.service.core.config.webUrl + '/dashboard')
}

@Get('me')
@UseGuards(ApiAnonJwtGuard)
async getMe(@Req() req: AuthRequest) {
Expand Down
23 changes: 23 additions & 0 deletions libs/api/core/data-access/src/lib/api-core-config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'
import { CookieOptions } from 'express-serve-static-core'
import { ApiCoreConfig } from './config/configuration'
import { AppConfig } from './entity/app-config.entity'
import { IdentityProvider } from '@prisma/client'

@Injectable()
export class ApiCoreConfigService {
Expand All @@ -12,6 +13,7 @@ export class ApiCoreConfigService {
get appConfig(): AppConfig {
return {
authDiscordEnabled: this.authDiscordEnabled,
authGithubEnabled: this.authGithubEnabled,
authPasswordEnabled: this.authPasswordEnabled,
authRegisterEnabled: this.authRegisterEnabled,
authSolanaEnabled: this.authSolanaEnabled,
Expand All @@ -26,6 +28,14 @@ export class ApiCoreConfigService {
return this.service.get<boolean>('authDiscordEnabled') ?? false
}

get authGithubAdminIds() {
return this.service.get<string[]>('authGithubAdminIds')
}

get authGithubEnabled(): boolean {
return this.service.get<boolean>('authGithubEnabled') ?? false
}

get authPasswordEnabled(): boolean {
return this.service.get<boolean>('authPasswordEnabled') ?? false
}
Expand Down Expand Up @@ -110,4 +120,17 @@ export class ApiCoreConfigService {
get webUrl(): string {
return this.service.get<string>('webUrl') as string
}

isAdminId(provider: IdentityProvider, providerId: string) {
switch (provider) {
case IdentityProvider.Discord:
return this.authDiscordAdminIds?.includes(providerId) ?? false
case IdentityProvider.GitHub:
return this.authGithubAdminIds?.includes(providerId) ?? false
case IdentityProvider.Solana:
return this.authSolanaAdminIds?.includes(providerId) ?? false
default:
return false
}
}
}
8 changes: 8 additions & 0 deletions libs/api/core/data-access/src/lib/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface ApiCoreConfig {
apiUrl: string
authDiscordAdminIds: string[]
authDiscordEnabled: boolean
authGithubAdminIds: string[]
authGithubEnabled: boolean
authPasswordEnabled: boolean
authRegisterEnabled: boolean
authSolanaAdminIds: string[]
Expand All @@ -36,6 +38,8 @@ export interface ApiCoreConfig {
databaseReset: boolean
discordClientId: string
discordClientSecret: string
githubClientId: string
githubClientSecret: string
host: string
port: number
webUrl: string
Expand All @@ -46,6 +50,8 @@ export function configuration(): ApiCoreConfig {
apiUrl: process.env['API_URL'] as string,
authDiscordAdminIds: getFromEnvironment('AUTH_DISCORD_ADMIN_IDS'),
authDiscordEnabled: process.env['AUTH_DISCORD_ENABLED'] === 'true',
authGithubAdminIds: getFromEnvironment('AUTH_GITHUB_ADMIN_IDS'),
authGithubEnabled: process.env['AUTH_GITHUB_ENABLED'] === 'true',
authPasswordEnabled: process.env['AUTH_PASSWORD_ENABLED'] === 'true',
authRegisterEnabled: process.env['AUTH_REGISTER_ENABLED'] === 'true',
authSolanaAdminIds: getFromEnvironment('AUTH_SOLANA_ADMIN_IDS'),
Expand All @@ -60,6 +66,8 @@ export function configuration(): ApiCoreConfig {
databaseReset: process.env['DATABASE_RESET'] === 'true',
discordClientId: process.env['DISCORD_CLIENT_ID'] as string,
discordClientSecret: process.env['DISCORD_CLIENT_SECRET'] as string,
githubClientId: process.env['GITHUB_CLIENT_ID'] as string,
githubClientSecret: process.env['GITHUB_CLIENT_SECRET'] as string,
host: process.env['HOST'] as string,
port: parseInt(process.env['PORT'] as string, 10) || 3000,
webUrl: WEB_URL,
Expand Down
4 changes: 4 additions & 0 deletions libs/api/core/data-access/src/lib/config/validation-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export const validationSchema = Joi.object({
API_URL: Joi.string().required().error(new Error(`API_URL is required.`)),
AUTH_DISCORD_ADMIN_IDS: Joi.string(),
AUTH_DISCORD_ENABLED: Joi.boolean().default(true),
AUTH_GITHUB_ADMIN_IDS: Joi.string(),
AUTH_GITHUB_ENABLED: Joi.boolean().default(true),
AUTH_PASSWORD_ENABLED: Joi.boolean().default(true),
AUTH_REGISTER_ENABLED: Joi.boolean().default(true),
AUTH_SOLANA_ADMIN_IDS: Joi.string(),
Expand All @@ -16,6 +18,8 @@ export const validationSchema = Joi.object({
DATABASE_URL: Joi.string(),
DISCORD_CLIENT_ID: Joi.string().required(),
DISCORD_CLIENT_SECRET: Joi.string().required(),
GITHUB_CLIENT_ID: Joi.string().required(),
GITHUB_CLIENT_SECRET: Joi.string().required(),
GRAPHQL_PLAYGROUND: Joi.boolean().default(false),
JWT_SECRET: Joi.string().required(),
HOST: Joi.string().default('0.0.0.0'),
Expand Down
2 changes: 2 additions & 0 deletions libs/api/core/data-access/src/lib/entity/app-config.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export class AppConfig {
@Field()
authDiscordEnabled!: boolean
@Field()
authGithubEnabled!: boolean
@Field()
authPasswordEnabled!: boolean
@Field()
authRegisterEnabled!: boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { VerifyIdentityChallengeInput } from './dto/verify-identity-challenge-in
import { sha256 } from './helpers/sha256'
import { ApiSolanaIdentityService } from './api-solana-identity.service'
import { ApiAuthService } from '@pubkey-stack/api-auth-data-access'
import { UserRole, UserStatus } from '@prisma/client'
import { IdentityProvider, UserRole, UserStatus } from '@prisma/client'

@Injectable()
export class ApiAnonIdentityService {
Expand All @@ -36,7 +36,7 @@ export class ApiAnonIdentityService {

// Generate a random challenge
const challenge = sha256(`${Math.random()}-${ip}-${userAgent}-${provider}-${providerId}-${Math.random()}`)
const admin = this.core.config.authSolanaAdminIds?.includes(providerId)
const admin = this.core.config.isAdminId(IdentityProvider.Solana, providerId)
// Store the challenge
return this.core.data.identityChallenge.create({
data: {
Expand Down
Loading

0 comments on commit f83af5a

Please sign in to comment.