dbAuth is here: host your own authentication, the old fashioned way

Ever since Redwood 0.1 launched in March of 2020, I’ve been on a mission. That mission was to be able to log in to my app without relying on a third-party service to host and manage user details (and bill my credit card for the privilege). I’ve got a database already—why can’t I use it to log in and sign up users, the way it’s been done for thousands of years? A couple of months ago I decided to do something about it.

With the release of Redwood 0.35, I can finally declare MISSION ACCOMPLISHED! Redwood now provides a local authentication option using your own database! We’ve got in-depth documentation available, but here are the basics:

Setup

There’s a new command that installs the api function required for dbAuth, as well as adding an environment variable for managing an encrypted cookie, and includes instructions for adding required fields to your local user database table:

yarn rw setup auth dbAuth

Be sure to read those post-install instructions as they contain all the steps you need to complete before auth will actually start working.

Technically you’re now able to authenticate with dbAuth! However, you still need a login and/or signup page. You didn’t think we’d leave you to have to write those by yourself, did you?

Scaffolding Login/Signup Pages

A new generator enters the scene and creates a simple login and signup page, copying the styling from the scaffold generator. You’re free to re-style as you see fit, or just use the logic in the pages to hand craft your own pages from scratch:

yarn rw g scaffold dbAuth

Again, read through the post-install instructions for a couple steps to take to customize these pages for your app.

How it Works

For those interested in the nitty gritty details, read on. Redwood’s dbAuth is modeled on the simple authentication systems recommended by, big surprise, Rails development before there were lots of packages (Ruby calls them “gems”) that would do it for you:

  1. Store a hashed password and salt along with the user record in the database.
  2. When a user tries to log in, grab them by the username they entered (probably an email address) and then salt and hash their submitted password. If it matches the hashed password in the database, then we trust that they are them.
  3. Create an encrypted cookie containing the user’s id (the cookie is also marked as HttpOnly, Secure and SameSite). The encryption key is an environment variable called SESSION_SECRET that’s created when the setup command is run.
  4. On each and every request to the api side, make sure the cookie can be decrypted and double check that the user still exists in the database.
  5. On logout, remove the cookie by setting the expiration date to the epoch (Jan 1, 1970).

The cookie cannot be accessed via Javascript on the client side (HttpOnly) or via cross-site requests (SameSite). By default the cookie is only available on the same domain that set it, but if you need to access it from a subdomain, there’s a config option for that.

If we detect any shenanigans, like the cookie can no longer be decrypted properly, then we assume it was tampered with and we immediately log the user out.

The Nuclear Option

Due to the fact that the session cookie is encrypted and decrypted by a secret that lives in your environment variables, what happens if you change that secret between deploys? Every user is logged out of your app on their request.

While this may seem like an extreme measure, if you ever find yourself in this situation you’ll be glad you have the option!

The SESSION_SECRET is stored in an .env file which, by default, is added to .gitignore so that it will NOT be committed to your repository. This is an absolute requirement, as anyone with access to that secret could decrypt the cookie. You should set a different secret for every developer on the team, and a separate one for each environment (qa, staging, production…).

Coming Soon

This implementation also takes the first steps towards CSRF protection—we’re setting a CSRF token in the cookie, as well as setting it in a header back to the client. We’re not sending the token back up the api-side on each request and verifying it, but we hope to add that functionality soon.

Comments?

Have you been looking forward to database-backed auth? Is there no way in hell you’d ever use this? Let us know!

10 Likes

This is amazing, great work all!

The Ethereum Auth should be extended to use this. If anyone is interested in working on this I’m happy to make a bounty for it.

1 Like

How would that work? I’m not really familiar with the Ethereum auth package, but I assume it uses your wallet, like Metamask, to somehow validate you? Wouldn’t it then need to verify with the extension that you’re still who you say you are, and are still logged in? Using the cookie method we have here would bypass that check completely?

I don’t know when auth got so complex, I created dbAuth specifically to make it simple again! :slight_smile:

Great job @rob, this is the one I’ve been waiting for! I never understood the third-party services love as a Rails developer.

Wow, I never expected a trailer for a new feature, but now I can’t live without one for each new feature! :astonished:

2 Likes

Looks like ethAuth and dbAuth are very similar. Only major difference is instead of email/password, they are signing a message with their wallet (Metamask). Both rely on the db/api to validate and store user auth data

I’m really happy to have a solution for self-host auth!
I tried to find a way to reset the password, maybe I can write some code inside functions/auth.ts to handle this, but I’m scare to create security issues. Do you have recommendations for that ?

2 Likes

Yeah, a forgot password flow didn’t make the cut, but we do want one!

What you could do today is something along these lines:

  • add a resetToken field to the user table that holds a UUID
  • create a “Forgot Password” page, that accepts an email address
  • when the user fills it out, assuming their email is found, set the resetToken to a UUID and email them a URL with that token in a query string variable
  • create a “Reset Your Password” page that checks that the reset token is found, if so then let them enter a plain text password (and nullify the resetToken field in the DB so it can’t be re-used)
  • salt and hash the password and save to the user record (take a look at the code here and follow the function calls for how we’re hashing/salting, we might move that to a shared location so you can just import and use it in your own code): redwood/DbAuthHandler.ts at main · redwoodjs/redwood · GitHub
  • send user back to the login screen and they should be good to go!

I was thinking about abstracting most of this and maybe just exposing a forgotPasswordHandler handler, similar to the signupHandler we have now in the auth.js function, where you define what you want to do when someone says their password wasn’t found. You would put your logic for however you want to notify the user (send an email, probably) and we’d handle the rest. We could generate the Forgot Password and Reset Your Password pages, and you could use the default, or re-style them to match your site.

3 Likes

Thank you ! :grinning_face_with_smiling_eyes: That is what I was looking for

1 Like

Does someone try to implement dbAuth on they current project ?
I try to find out why I have this weird error from apollo :

"TypeError: Converting circular structure to JSON"
1: "    --> starting at object with constructor 'Array'"
2: "    |     index 2 -> object with constructor 'Object'"
3: "    |     property 'context' -> object with constructor 'Object'"
4: "    --- property 'currentUser' closes the circle"
5: "    at JSON.stringify (<anonymous>)"
6: "    at prettyJSONStringify (/Users/simongagnon/Projects/raccoon/node_modules/apollo-server-core/src/runHttpQuery.ts:455:15)"

With a code 500 to /graphql when it’s call this query

query __REDWOOD__AUTH_GET_CURRENT_USER { redwood { currentUser } }

The __REDWOOD__AUTH_GET_CURRENT_USER query runs the getCurrentUser() function you have defined in api/src/lib/auth.js It sounds like your user user record is referencing something that then references user again, making a circular dependency.

We’ve also seen weird errors after upgrading from a previous version of Redwood, and the only fix seems to be deleting all of node_modules and reinstalling everything from scratch again. You could also try that.

If you’re still getting the same error, would you mind sharing the SDL file for your user, as well as the content of api/src/lib/auth.js?

1 Like

Yes, I know that query run getCurrentUser() so I put a console.log() to see what’s happen like this :

export const getCurrentUser = async (session: { id: string }) => {
  console.log('getCurrentUser', session)
  return await db.agent.findUnique({ where: { id: session.id } })
}

And… I see nothing from my console :sweat_smile: So I was like yeah, I think the problem is before it execute the function. I tried to debug from the framework and I did’t find anything that can cause this circular structure.

My sdl for agent look like this :

  type Agent {
    email: String!
    id: String
    createdAt: DateTime!
    firstName: String!
    lastName: String!
    role: String!
    Partner: Partner!
    partnerId: String!
    notes: [Note]
    kanban: JSON
  }

And I set authModelAccessor: 'agent', for /auth.ts

Thank you for your help and also thanks for dbAuth :grinning_face_with_smiling_eyes:

Don’t thank me yet, it’s not working! :sweat_smile:

Okay, I don’t see a hashedPassword or salt field on your Agent model. You’ll need to add those so that dbAuth can store the hashed password for the user! There were instructions for adding those after the yarn rw setup auth dbAuth command, but you can see them again here: Docs - Authentication : RedwoodJS Docs

Did you update authFields and tell it that you’re using email as your username field? You’ll see that mentioned in the instructions as well.

You won’t have any users that have a valid password in your database yet, so you’ll want to sign up to create the first one. Then, see if you can log in as that user!

Oh yes I have it in the prisma.schema, I was thinking I should not put this on the GraphQL.
It don’t seem to change something now that is done.
Is also all good for authFields.

So yes I have a user with a valid password and it’s work, I also receive the id in the network when it call /auth?method=getToken

I wrote a little function in my seed to create a god user in dev ( all credit to you )

import * as CryptoJS from 'crypto-js'

function _hashPassword(text: string) {
  const useSalt = CryptoJS.lib.WordArray.random(128 / 8).toString()

  return [
    CryptoJS.PBKDF2(text, useSalt, { keySize: 256 / 32 }).toString(),
    useSalt,
  ]
}

const [hashedPassword, salt] = _hashPassword(process.env['MYPASSWORD'])

export default [
  {
    email: 'god@raccoon.trade',
    firstName: 'Mr',
    lastName: 'Raccoon',
    role: 'admin',
    partnerId: '00000000-0000-0000-0000-000000000000',
    kanban: '{"lanes":[]}',
    hashedPassword: hashedPassword,
    salt: salt,
  },
]

HMMMM It seems like everything is in place then…

This line in the error is interesting: starting at object with constructor 'Array' since we’re only selecting a single user I’m not sure where an Array would be coming from. Do you have a unique constraint on Agent.email?

I also noticed that id is not required in the SDL…is it required and being set properly in the database though?

1 Like

Yes

  id             String   @id @default(uuid())
  email          String   @unique

But that give me a good path to continue my research, I will keep you update !
Also is it normal that the Bearer token is the id of the user ?

I was able to create a new app from scratching using Agent as the user model and it’s working fine for me. Let me know if you want to see the code and I can put it up on GitHub!

Finally found out ! :star_struck:
After learn about how all the auth in redwood work, I discover that I’m missing something for my GraphQL, and I will explain why.

So when I was doing the Code Modifications for V0.35, I copy-past completely the handler like this :

export const handler = createGraphQLHandler({
  loggerConfig: { logger, options: {} },
  schema: makeMergedSchema({
    schemas,
    services: makeServices({ services }),
  }),
  onException: () => {
    // Disconnect from your database with an unhandled exception.
    db.$disconnect()
  },
})

So, by out of sheer ignorance, I remove the import { getCurrentUser } from 'src/lib/auth' telling me why this is here!

I finally get it by reading this code from the framework :

// packages/api/src/functions/graphql.ts
...
    // If the request contains authorization headers, we'll decode the providers that we support,
    // and pass those to the `currentUser`.
    const authContext = await getAuthenticationContext({ event, context })
    if (authContext) {
      context.currentUser = getCurrentUser
        ? await getCurrentUser(authContext[0], authContext[1], authContext[2])
        : authContext
    }

This ternary operator check if it can get getCurrentUser from createGraphQLHandler. Guest what I remove it, so it gives me authContext.

I don’t know why we give authContext if getCurrentUser is not available, but now I know that this thing give a circular structure and also why I have import { getCurrentUser } from 'src/lib/auth'

1 Like

Glad you figured it out! :slight_smile:

1 Like

Please consider supporting https://next-auth.js.org/

Loving it so far @rob!

Is a loginHandler on the horizon? Feature-wise I’m thinking of a confirmation step between signup and any attempted login, where an email gets sent as part of signup handling and that login handler would reject on error, similar to beforeResolver.

forgotPasswordHandler seems like a good idea, +1 to it.

2 Likes