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: oauth with github #7

Merged
merged 11 commits into from
Oct 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 4 additions & 2 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:

jobs:
deploy:
name: Deploy
name: Production
runs-on: ubuntu-latest
environment: Production

Expand Down Expand Up @@ -40,7 +40,9 @@ jobs:
- name: Generate .env file
uses: SpicyPizza/[email protected]
with:
envkey_DATABASE_URL: ${{ secrets.DATABASE_URL }}
envkey_DATABASE_URL: ${{ env.DATABASE_URL }}
envkey_OAUTH_GITHUB_CLIENT_ID: ${{ secrets.OAUTH_GITHUB_CLIENT_ID }}
envkey_OAUTH_GITHUB_CLIENT_SECRET: ${{ secrets.OAUTH_GITHUB_CLIENT_SECRET }}

- name: Run migrations
run: bun run kysely -- migrate:latest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:

jobs:
cleanup:
name: Cleanup
name: PR
runs-on: ubuntu-latest
environment: Preview

Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/pr.yml → .github/workflows/preview.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: PR
name: Preview

on:
pull_request:
Expand All @@ -8,7 +8,7 @@ on:
- synchronize

jobs:
pr:
preview:
name: PR
runs-on: ubuntu-latest
environment: Preview
Expand Down Expand Up @@ -61,6 +61,8 @@ jobs:
uses: SpicyPizza/[email protected]
with:
envkey_DATABASE_URL: ${{ env.DATABASE_URL }}
envkey_OAUTH_GITHUB_CLIENT_ID: ${{ secrets.OAUTH_GITHUB_CLIENT_ID }}
envkey_OAUTH_GITHUB_CLIENT_SECRET: ${{ secrets.OAUTH_GITHUB_CLIENT_SECRET }}

- name: Run migrations
run: bun run kysely -- migrate:latest
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@
- [x] Emails (Resend)
- [x] Login/Register
- [x] Nick, email, password
- [ ] OAuth providers
- [ ] GitHub
- [x] OAuth providers
- [x] GitHub
- [ ] Google
- [ ] Facebook
- [ ] X/Twitter
Expand Down
6 changes: 3 additions & 3 deletions api/actions/admin.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { kv } from '../core/app.ts'
import { Context } from '../core/router.ts'
import { Context, RouteError } from '../core/router.ts'
import { sessions } from '../core/sessions.ts'
import { db } from '../db.ts'
import { actions, RpcError } from '../routes/rpc.ts'
import { actions } from '../routes/rpc.ts'
import { UserSession } from '../schemas/user.ts'

export const ADMINS = ['x', 'stagas']

function admins(ctx: Context) {
const session = sessions.get(ctx)
if (!session || !ADMINS.includes(session.nick)) {
throw new RpcError(403, 'Forbidden')
throw new RouteError(403, 'Forbidden')
}
}

Expand Down
104 changes: 74 additions & 30 deletions api/actions/login-register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,39 @@ import { hash } from 'jsr:@denorg/[email protected]'
import { createCookie, randomHash, timeout } from 'utils'
import { kv } from '../core/app.ts'
import { SALT as salt } from '../core/constants.ts'
import { Context } from '../core/router.ts'
import { Context, RouteError } from '../core/router.ts'
import { sendEmail } from '../core/send-email.ts'
import { sessions } from '../core/sessions.ts'
import { db } from '../db.ts'
import { env } from '../env.ts'
import { actions, RpcError } from '../routes/rpc.ts'
import { User, UserLogin, UserRegister, UserSession } from '../schemas/user.ts'
import { actions } from '../routes/rpc.ts'
import { UserLogin, UserRegister, UserSession } from '../schemas/user.ts'
import { ADMINS } from './admin.ts'
import { User } from '../models.ts'

// const DEBUG = true

class LoginError extends RpcError {
class LoginError extends RouteError {
constructor() { super(403, 'Wrong user or password') }
}

class UserExistsError extends RpcError {
class UserExistsError extends RouteError {
constructor() { super(409, 'User already exists') }
}

class UnableToRegisterError extends RpcError {
class UnableToRegisterError extends RouteError {
constructor() { super(500, 'Unable to register') }
}

class UserEmailAlreadyVerified extends RpcError {
class UserEmailAlreadyVerified extends RouteError {
constructor() { super(404, 'Email already verified') }
}

class UserNotFound extends RpcError {
class UserNotFound extends RouteError {
constructor() { super(404, 'User not found') }
}

class TokenNotFound extends RpcError {
class TokenNotFound extends RouteError {
constructor() { super(404, 'Token not found') }
}

Expand All @@ -58,7 +59,7 @@ export async function getUser(nickOrEmail: string) {
return await getUserByNick(nickOrEmail) || await getUserByEmail(nickOrEmail)
}

async function loginUser(ctx: Context, nick: string) {
export async function loginUser(ctx: Context, nick: string) {
ctx.log('Login:', nick)

const sessionId = randomHash()
Expand All @@ -80,6 +81,7 @@ async function loginUser(ctx: Context, nick: string) {
'session',
sessionId,
expires,
'Path=/',
'SameSite=Strict',
'Secure',
'HttpOnly',
Expand All @@ -106,7 +108,7 @@ async function generateEmailVerificationToken(email: string) {
actions.post.whoami = whoami
export async function whoami(ctx: Context) {
const session = sessions.get(ctx)
return session
return session ?? null
}

actions.post.login = login
Expand All @@ -124,23 +126,66 @@ export async function login(ctx: Context, userLogin: UserLogin) {
}

actions.post.register = register
export async function register(ctx: Context, userRegister: UserRegister) {
const { nick, email, password } = userRegister

if (await getUserByNick(nick) || await getUserByEmail(email)) {
throw new UserExistsError()
export async function register(ctx: Context, userRegister: UserRegister, oauthField?: 'oauthGithub') {
const { nick, email } = userRegister

const userByNick = await getUserByNick(nick)
const userByEmail = await getUserByEmail(email)

// user nick or email exists
if (userByNick || userByEmail) {
// user has registered with password before and is now logging in with oauth
if (oauthField && userByEmail) {
await db
.updateTable('user')
.where('email', '=', email)
.set('emailVerified', true)
.set(oauthField, true)
.executeTakeFirstOrThrow(UnableToRegisterError)
}
// user has logged in with oauth before and is now registering with password
else if (userByEmail?.emailVerified && 'password' in userRegister && !userByEmail.password) {
await db
.updateTable('user')
.where('email', '=', email)
.set('password', hash(userRegister.password, { salt }))
.executeTakeFirstOrThrow(UnableToRegisterError)
}
else {
throw new UserExistsError()
}
}
// user is new
else {
let values: UserRegister
// user registers with password
if ('password' in userRegister) {
values = {
nick,
email,
password: hash(userRegister.password, { salt })
}
}
// user registers with oauth
else if (oauthField) {
values = {
nick,
email,
emailVerified: true,
// @ts-ignore ts has issues with dynamic keys even though it is typed
[oauthField]: true
}
}
else {
throw new RouteError(400, 'Invalid registration')
}

await db.insertInto('user')
.values({
nick,
email,
emailVerified: false,
password: hash(password, { salt })
})
.executeTakeFirstOrThrow(UnableToRegisterError)
await db.insertInto('user')
.values(values)
.executeTakeFirstOrThrow(UnableToRegisterError)

sendVerificationEmail(ctx, email).catch(ctx.log)
if (!oauthField) sendVerificationEmail(ctx, email).catch(ctx.log)
}

return loginUser(ctx, nick)
}
Expand Down Expand Up @@ -173,7 +218,7 @@ If you did not register, simply ignore this email.`,
})

if (!result.ok) {
throw new RpcError(result.error.statusCode, result.error.message)
throw new RouteError(result.error.statusCode, result.error.message)
}
}

Expand Down Expand Up @@ -248,7 +293,7 @@ If you did not request a password reset, simply ignore this email.`,
})

if (!result.ok) {
throw new RpcError(result.error.statusCode, result.error.message)
throw new RouteError(result.error.statusCode, result.error.message)
}
}

Expand All @@ -259,9 +304,8 @@ export async function getResetPasswordUser(_ctx: Context, token: string) {
if (result.value) {
const user = await getUserByNick(result.value)
if (user) {
// @ts-ignore remove password before returning
delete user.password
return user as Omit<User, 'password'>
user.password = null
return user
}
}

Expand Down
31 changes: 31 additions & 0 deletions api/actions/oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { kv } from '../core/app.ts'
import { Context, RouteError } from '../core/router.ts'
import { OAuthLogin } from '../routes/oauth/github.ts'
import { actions } from '../routes/rpc.ts'
import * as loginRegisterActions from './login-register.ts'

function pascalCase(s: string) {
return s.replace(/(^|-)([a-z])/g, (_, __, c) => c.toUpperCase())
}

actions.get.getLoginSession = getLoginSession
export async function getLoginSession(_ctx: Context, id: string) {
const entry = await kv.get(['oauth', id])
if (!entry.value) throw new RouteError(404, 'OAuth session not found')
const session = OAuthLogin.parse(entry.value)
return { login: session.login }
}

actions.post.registerOAuth = registerOAuth
export async function registerOAuth(ctx: Context, id: string, nick: string) {
const entry = await kv.get(['oauth', id])
if (!entry.value) throw new RouteError(404, 'OAuth session not found')
const target = 'oauth' + pascalCase(id.split('-')[0]) as 'oauthGithub'
const session = OAuthLogin.parse(entry.value)
return await loginRegisterActions.register(ctx, {
nick,
email: session.email,
// @ts-ignore ts has issues with dynamic keys
[target]: true
}, target)
}
7 changes: 7 additions & 0 deletions api/core/fetch-json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export async function fetchJson(url: string, init: RequestInit) {
const res = await fetch(url, init)
if (!res.ok) {
throw new Error(`Fetch error ${res.status}`)
}
return res.json()
}
13 changes: 11 additions & 2 deletions api/core/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export const files = (root: string): Handler => async ctx => {
const { pathname } = ctx.url
let file
let filepath
let error
out:
try {
filepath = path.join(pathname, 'index.html')
Expand All @@ -86,10 +87,18 @@ export const files = (root: string): Handler => async ctx => {
break out
}
catch (e) {
ctx.log('Error serving:', filepath, e)
error = e
if (e instanceof Deno.errors.NotFound) {
return
try {
filepath = '/index.html'
file = await Deno.open(`${root}${filepath}`, { read: true })
break out
}
catch (e) {
error = e
}
}
ctx.log('Error serving:', filepath, error)
}
return new Response(null, { status: 500 })
}
Expand Down
10 changes: 10 additions & 0 deletions api/core/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { defer, parseCookie } from 'utils'
import { z } from 'zod'
import { match } from './match.ts'

const DEBUG = false

const headers: Record<string, string> = {
'access-control-allow-methods': 'GET, HEAD, OPTIONS, POST, PUT',
'access-control-allow-headers': 'Origin, X-Requested-With, Content-Type, Accept, Authorization',
Expand All @@ -28,6 +30,13 @@ interface Route {
handle: Handler
}

export class RouteError extends Error {
declare cause: { status: number }
constructor(status: number, message: string) {
super(message, { cause: { status } })
}
}

function redirect(status: number, location: string) {
return new Response(null, { status, headers: { location } })
}
Expand Down Expand Up @@ -90,6 +99,7 @@ export function Router({ log = console.log }: { log?: typeof console.log } = {})
return async function handler(ctx) {
let m: ReturnType<ReturnType<typeof match>> | undefined
if (matcher) m = matcher(ctx.url.pathname)
DEBUG && ctx.log('match:', ctx.url.pathname, 'to:', path, m)
if (path === null || m) {
ctx.params = m && m.params || {}
for (const fn of fns) {
Expand Down
5 changes: 5 additions & 0 deletions api/core/server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import os from 'https://deno.land/x/[email protected]/src/mod.deno.ts'
import * as path from 'jsr:@std/path'
import * as rpc from '../routes/rpc.ts'
import * as oauthCommon from '../routes/oauth/common.ts'
import * as oauthGitHub from '../routes/oauth/github.ts'
import { app } from './app.ts'
import { IS_DEV } from './constants.ts'
import { cors, files, logger, session, watcher } from './middleware.ts'
import '../actions/oauth.ts'

const dist = 'dist'
const home = os.home() ?? '~'
Expand All @@ -21,6 +24,8 @@ app.use(null, [logger])
app.use(null, [cors])
app.use(null, [session])

oauthCommon.mount(app)
oauthGitHub.mount(app)
rpc.mount(app)

IS_DEV && app.log('Listening: https://devito.test:8000')
Expand Down
Loading
Loading