Skip to content
Closed
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
2 changes: 2 additions & 0 deletions packages/cli/src/commands/setup/auth/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const OUTPUT_PATHS = {
getPaths().api.functions,
getProject().isTypeScriptProject ? 'auth.ts' : 'auth.js'
),
service: path.join(getPaths().api.services, '/ethereumAuth/ethereumAuth.js'),
graphql: path.join(getPaths().api.graphql, 'ethereumAuth.sdl.js'),
Comment on lines +26 to +27
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this feels out of place here. Understood you'll need service and graphql path included, but hardcoding these for Ethereum Auth here probably isn't the right approach. Maybe modify files() so you can pass config from providers/etherum.js that effectively adds to the returned files? Something like this would be more extensible for any/all auth providers.

Any other (better) thoughts about this @dthyresson?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh you weren't supposed to find that 🙃

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, the need here is to have the setup command:

  • add SDL
  • add a service
    ?

}

const getGraphqlPath = () =>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import { AuthenticationError } from '@redwoodjs/api'
import { AuthenticationError, ForbiddenError, parseJWT } from '@redwoodjs/api'

import { db } from './db'

// See https://redwoodjs.com/cookbook/role-based-access-control-rbac
// for how to add Role-based Access Control (RBAC) here.

export const getCurrentUser = async (decoded) => {
return db.user.findUnique({ where: { address: decoded.address } })
/**
* getCurrentUser returns the user information together with
* an optional collection of roles used by requireAuth() to check
* if the user is authenticated or has role-based access
*
* @param decoded - The decoded access token containing user info and JWT claims like `sub`
* @param { token, SupportedAuthTypes type } - The access token itself as well as the auth provider type
* @param { APIGatewayEvent event, Context context } - An object which contains information from the invoker
* such as headers and cookies, and the context information about the invocation such as IP Address
*
* @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples
*/
export const getCurrentUser = async (
decoded,
{ _token, _type },
{ _event, _context }
) => {
return { ...decoded, roles: parseJWT({ decoded }).roles }
}

/**
Expand All @@ -18,11 +29,55 @@ export const isAuthenticated = () => {
return !!context.currentUser
}

// Use this function in your services to check that a user is logged in, and
// optionally raise an error if they're not.
/**
* Checks if the currentUser is authenticated (and assigned one of the given roles)
*
* @param {string | string[]} roles - A single role or list of roles to check if the user belongs to
*
* @returns {boolean} - Returns true if the currentUser is logged in and assigned one of the given roles,
* or when no roles are provided to check against. Otherwise returns false.
*/
export const hasRole = ({ roles }) => {
if (!isAuthenticated()) {
return false
}

if (roles) {
if (Array.isArray(roles)) {
return context.currentUser.roles?.some((r) => roles.includes(r))
}

if (typeof roles === 'string') {
return context.currentUser.roles?.includes(roles)
}

export const requireAuth = () => {
if (!context.currentUser) {
// roles not found
return false
}

return true
}

/**
* Use requireAuth in your services to check that a user is logged in,
* whether or not they are assigned a role, and optionally raise an
* error if they're not.
*
* @param {string= | string[]=} roles - A single role or list of roles to check if the user belongs to
*
* @returns - If the currentUser is authenticated (and assigned one of the given roles)
*
* @throws {AuthenticationError} - If the currentUser is not authenticated
* @throws {ForbiddenError} If the currentUser is not allowed due to role permissions
*
* @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples
*/
export const requireAuth = ({ roles } = {}) => {
if (!isAuthenticated()) {
throw new AuthenticationError("You don't have permission to do that.")
}

if (!hasRole({ roles })) {
throw new ForbiddenError("You don't have access to do that.")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export const schema = gql`
type AuthChallengeResult {
message: String!
}

type AuthVerifyResult {
token: String!
}

input AuthChallengeInput {
address: String!
options: JSON
}

input AuthVerifyInput {
signature: String!
address: String!
options: JSON
}

type Mutation {
authChallenge(input: AuthChallengeInput!): AuthChallengeResult
authVerify(input: AuthVerifyInput!): AuthVerifyResult
}
`
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { AuthenticationError } from '@redwoodjs/api'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you considered instead of having this be a service with SDL, it is just a serverless function?

Does it need to be exposed to as GraphQL?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah you bring up a good point. Now that I've learned how to use dbAuth, I'm thinking that I should completely re-haul the ETH Auth to piggyback on dbAuth. This is a lot, so I may need to move this to draft and come back to it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. We can move to draft and definitely review when you've had some time to rethink if need.


import { bufferToHex } from 'ethereumjs-util'
import { recoverPersonalSignature } from 'eth-sig-util'
import jwt from 'jsonwebtoken'

import { db } from 'src/lib/db'

const NONCE_MESSAGE =
'Please prove you control this wallet by signing this random text: '

const getNonceMessage = (nonce, options) => {
let optionsText = ''
if (options)
optionsText =
'&' +
Object.keys(options)
.map(
(key) =>
encodeURIComponent(key) + '=' + encodeURIComponent(options[key])
)
.join('&')
return NONCE_MESSAGE + nonce + optionsText
}

export const beforeResolver = (rules) => {
rules.skip({ only: ['authChallenge', 'authVerify'] })
}

export const authChallenge = async ({
input: { address: addressRaw, options },
}) => {
const nonce = Math.floor(Math.random() * 1000000).toString()
const address = addressRaw.toLowerCase()
await db.user.upsert({
where: { address },
update: {
authDetail: {
update: {
nonce,
timestamp: new Date(),
},
},
},
create: {
address,
authDetail: {
create: {
nonce,
},
},
},
})

return { message: getNonceMessage(nonce, options) }
}

export const authVerify = async ({
input: { signature, address: addressRaw, options },
}) => {
try {
const address = addressRaw.toLowerCase()
const user = await db.user.findUnique({
where: { address },
})
if (!user) throw new Error('No authentication started')
const { nonce, timestamp } = await db.user
.findUnique({
where: { address },
})
.authDetail()

const startTime = new Date(timestamp)
if (new Date() - startTime > 5 * 60 * 1000)
throw new Error(
'The challenge must have been generated within the last 5 minutes'
)
const signerAddress = recoverPersonalSignature({
data: bufferToHex(Buffer.from(getNonceMessage(nonce, options), 'utf8')),
sig: signature,
})
if (address !== signerAddress.toLowerCase())
throw new Error('invalid signature')

const token = jwt.sign(
{ address, id: user.id },
process.env.ETHEREUM_JWT_SECRET,
{
expiresIn: '5h',
}
)
return { token }
} catch (e) {
throw new Error(e)
}
}