-
-
Save cannikin/ef206cd25e2c2bddb69935a568074f3e to your computer and use it in GitHub Desktop.
Oauth implementation for RedwoodJS
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import CryptoJS from 'crypto-js' | |
import fetch from 'node-fetch' | |
import { db } from 'src/lib/db' | |
import { logger } from 'src/lib/logger' | |
import { createUser } from 'src/services/users' | |
export const handler = async (event, _context) => { | |
switch (event.path) { | |
case '/oauth/callback': | |
return await callback(event) | |
default: | |
return notFound(event) | |
} | |
} | |
const callback = async (event) => { | |
const { code, state } = event.queryStringParameters | |
const response = await fetch(`https://github.com/login/oauth/access_token`, { | |
method: 'POST', | |
headers: { | |
Accept: 'application/json', | |
'Content-Type': 'application/json', | |
}, | |
body: JSON.stringify({ | |
client_id: process.env.GITHUB_OAUTH_CLIENT_ID, | |
client_secret: process.env.GITHUB_OAUTH_CLIENT_SECRET, | |
code: code, | |
redirect_uri: process.env.GITHUB_OAUTH_REDIRECT_URI, | |
}), | |
}) | |
// eslint-disable-next-line camelcase | |
const { access_token, scope, error } = JSON.parse(await response.text()) | |
if (error) { | |
return errorStatus(error) | |
} | |
try { | |
const providerUser = await getProviderUser(access_token) | |
logger.info(`[OAUTH] User ${providerUser.login} attempting login`) | |
const user = await login(providerUser, access_token, scope, { state }) | |
return redirectToSite(secureCookie(user)) | |
} catch (e) { | |
return errorStatus(e.message) | |
} | |
} | |
// creates the cookie a logged in user should have as if they used dbAuth | |
const secureCookie = (user) => { | |
const expires = new Date() | |
expires.setFullYear(expires.getFullYear() + 1) | |
const cookieAttrs = [ | |
`Expires=${expires.toUTCString()}`, | |
'HttpOnly=true', | |
'Path=/', | |
'SameSite=Strict', | |
`Secure=${process.env.NODE_ENV !== 'development'}`, | |
] | |
const data = JSON.stringify({ id: user.id }) | |
const encrypted = CryptoJS.AES.encrypt( | |
data, | |
process.env.SESSION_SECRET | |
).toString() | |
return [`session=${encrypted}`, ...cookieAttrs].join('; ') | |
} | |
// create user first, then log them in | |
const login = async (providerUser, accessToken, scope, options) => { | |
const { user, identity } = await getLocalUser(providerUser, options) | |
await db.identity.update({ | |
where: { id: identity.id }, | |
data: { accessToken, scope, lastLoginAt: new Date() }, | |
}) | |
return user | |
} | |
const getProviderUser = async (token) => { | |
const response = await fetch('https://api.github.com/user', { | |
headers: { Authorization: `Bearer ${token}` }, | |
}) | |
const body = JSON.parse(await response.text()) | |
return body | |
} | |
const getLocalUser = async (providerUser, options) => { | |
let user, identity | |
// user has been here before and has an identity | |
identity = await db.identity.findFirst({ | |
where: { provider: 'github', uid: providerUser.id.toString() }, | |
include: { user: true }, | |
}) | |
if (identity) { | |
logger.info('[OAUTH] Identity found') | |
return { user: identity.user, identity } | |
} | |
// existing users that aren't already linked to GitHub | |
user = await db.user.findFirst({ | |
where: { username: providerUser.login }, | |
}) | |
if (user) { | |
identity = await createIdentity(user, providerUser) | |
logger.info('[OAUTH] User found, creating identity') | |
return { user, identity } | |
} else { | |
// new user, create account | |
const created = await createLocalUser(providerUser, options) | |
logger.info('[OAUTH] New user, creating') | |
return { user: created.user, identity: created.identity } | |
} | |
} | |
const createLocalUser = async (providerUser, options) => { | |
const [firstName, lastName] = providerUser.name?.split(' ') || [null, null] | |
// createUser() will validate invite code, just need to include it in the payload | |
const user = await createUser({ | |
input: { | |
username: providerUser.login, | |
email: providerUser.email || `${providerUser.login}@spoke.run`, | |
firstName: firstName, | |
lastName: lastName, | |
avatar: providerUser.avatar_url, | |
bio: providerUser.bio, | |
url: providerUser.blog, | |
invite: options?.state, | |
}, | |
}) | |
const identity = await createIdentity(user, providerUser) | |
return { user, identity } | |
} | |
const createIdentity = async (user, providerUser) => { | |
return await db.identity.create({ | |
data: { | |
provider: 'github', | |
uid: providerUser.id.toString(), | |
userId: user.id, | |
}, | |
}) | |
} | |
const redirectToSite = (cookie) => { | |
return { | |
statusCode: 302, | |
headers: { | |
'Set-Cookie': cookie, | |
Location: process.env.OAUTH_REDIRECT, | |
}, | |
} | |
} | |
const errorStatus = (message) => { | |
return { | |
statusCode: 302, | |
headers: { | |
Location: `/login?error=${message}`, | |
}, | |
} | |
} | |
const notFound = () => { | |
return { | |
statusCode: 404, | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
model Identity { | |
id Int @id @default(autoincrement()) | |
provider String | |
uid String | |
userId Int | |
user User @relation(fields: [userId], references: [id]) | |
accessToken String? | |
scope String? | |
lastLoginAt DateTime @default(now()) | |
createdAt DateTime @default(now()) | |
updatedAt DateTime @updatedAt | |
@@unique([provider, uid]) | |
@@index(userId) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment