Skip to content

Commit

Permalink
feat: oauth with github (#7)
Browse files Browse the repository at this point in the history
* feat: oauth with github

* fix: import paths

* fix: handle secrets

* fix: fallback to /index.html

* fix: origin

* fix: strip last /

* fix: complete oauth after nick register

* chore: relax codecov

* fix: relax codecov more

* fix: relax codecov even more

* fix: add patch thresholds in codecov
  • Loading branch information
stagas authored Oct 3, 2024
1 parent dcc2fb1 commit c6a8f54
Show file tree
Hide file tree
Showing 25 changed files with 445 additions and 77 deletions.
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

0 comments on commit c6a8f54

Please sign in to comment.