What is auth?

“Auth” refers to both authentication and authorization.

  • authentication - who is this person? And are they actually them? Verifies the identity of a user or service. (Sometimes abbreviated as authn)

  • authorization - can they do this thing or not?

  • Identity Provider (IdP) - the system that validates who you are, can be self-hosted or a cloud IdP, like Amazon Cognito, which has its own user pools or can use social providers like Facebook or Google

  • credentials - whatever is necessary to identify a user server-side (e.g. username, password, token)

  • single sign on (SSO) - authenticate the same user with the same credentials across websites or web apps (e.g. if the user is logged into web app A, they will also be logged into web app B, C, and D automatically)

  • 2FA (two-factor authentication) - A second means of authentication stacked on the first to increase security

  • MFA (multi-factor authentication) - Ability to use more than one means of credentials

  • OAuth 2.0 - a login flow that allows users to authenticate using a 3rd party server

  • JWT (JSON web token) - a format used to encode metadata for credentials

  • OTP (one-time password) - a code is given via SMS or email that a user may use as a single-use credential

  • public key cryptography - a public/private key relationship to enable login

Where does auth happen?

  • happens on the server (on requests)

Auth providers

  • should be incredibly simple
  • returns secure cookies from login successes
  • secure cookie is included on all subsequent requests to server

Authorization

  • server checks if user is authorized to preform specific action
  • in AWS Amplify, checks the
    @auth
    params on graphQL
    @model
  • in Kinde, use
    getKindeServerSession()
    hook to access permissions for the current user

Implementation

  • custom auth
    • write your own user/password flow
    • implement APIs like WebAuthn
  • Identity Providers
    • uses different specs like OpenID or SAML 2.0
    • “sign in with…” use another service’s database
  • Identity as a service
    • Auth0
    • NextAuth (Auth.js)
    • Kinde

Security risks, why we do it

  • man in the middle attacks
    • secretly intercepting and altering traffic
      • Signature stripping JWTs
      • Cross-site request forgery (CSRF)
      • Cross-site scripting (XSS)
  • keyloggers
  • easy to guess passwords
  • web servers and DB attacks
  • phishing and social engineering
    • manually extract credentials through fraud

Enhancing login forms

  • Connected labels for each element (e.g. use
    name
    )
  • Don't use placeholder as labels
  • Using HTML semantics
  • On SPAs, form names different for registration and login forms
  • Let the user make the password visible
  • Help Password Managers with autocomplete HTML attributes (e.g.
    autocomplete=“current-password”
    )
  • Help Accessibility with aria-described by attribute for instructions
  • On SPAs, use submit form event and submission will be triggered by a
    pushState

Form accessibility

  • Encapsulate within a
    <form>
    element with an
    id
  • Use
    for
    in
    <label>
    to enable larger touch area for input focus
  • autocomplete
    on
    <input>
    elements for better password manager integration

VanillaJS specifics

Using an Express.js server, we can set up routes for registration and login that are hit with POST requests from our login and registration forms when the user presses the submit button. While this is an incredibly simple implementation, it is in essence what is happening in our libraries behind-the-scenes.

import express from "express"; import { Low } from "lowdb"; import { JSONFile } from "lowdb/node"; import * as url from "url"; import bcrypt from "bcryptjs"; import * as jwtJsDecode from "jwt-js-decode"; import base64url from "base64url"; import SimpleWebAuthnServer from "@simplewebauthn/server"; import { ok } from "assert"; const __dirname = url.fileURLToPath(new URL(".", import.meta.url)); const app = express(); app.use(express.json()); const adapter = new JSONFile(__dirname + "/auth.json"); const db = new Low(adapter); await db.read(); db.data ||= { users: [] }; const rpID = "localhost"; const protocol = "http"; const port = 5050; const expectedOrigin = `${protocol}://${rpID}:${port}`; app.use(express.static("public")); app.use(express.json()); app.use( express.urlencoded({ extended: true, }) ); function findUser(email) { const results = db.data.users.filter((user) => user.email === email); if (results.length === 0) { return null; } return results[0]; } // ADD HERE THE REST OF THE ENDPOINTS app.post("/auth/login", (req, res) => { const user = findUser(req.body.email); if (!user) { res.send({ ok: false, message: "Credentials invalid." }); } else { } }); app.post("/auth/register", (req, res) => { // Hash the password with bcrypt const salt = bcrypt.genSaltSync(10); const hashedPass = bcrypt.hashSync(req.body.password, salt); // TODO: User validation const user = { name: req.body.name, email: req.body.email, password: hashedPass, }; const userFound = findUser(user.email); if (userFound) { res.send({ ok: false, message: "User already exists" }); } else { db.data.users.push(user); db.write(); res.send({ ok: true }); } });

We’re defining the current directory, and the

app
and telling it to parse (
app.use(express.json())
) all incoming requests.

Then we set up the local database using our

auth.json
file and define some configuration variables for the express server (
rpId
,
protocol
,
port
,
exprectedOrigin
).

Finally, we define the login and register routes (

app.post(“/auth/login”, (req, res) => ...
) and search for the user with our
findUser
function and either add the new user to db or send an error message to the client.

NextAuth specifics

NextAuth (now Auth.js) is a credentials library for Next (and now Expo) that handles basic auth flow and session management.

When

createTRPCContext
is fired, a session is returned using NextAuth options.

Adapter

To connect with your database and handle accounts, users, and sessions automatically.

Session strategy

The reason a session is necessary is so that requests to the API are signed by a user with valid permission to access the data returned in the response.

JWT (JSON Web Token)

Database

If using an adapter, database is used automatically unless explicitly defined ‘jwt’. The session cookie will only contain a

sessionToken
value which is used to look up the session in the database.

Providers

NextAuth has a slew of federated identity systems integrated that allow for 3rd party servers to handle authentication with users already present in their databases. Google and Facebook are two of the most popular choices but Discord, Github, and even Dribbble have NextAuth providers, with over 80 preconfigured.

Credentials Provider

NextAuth also providers the [limited] roll-your-own credentials provider for those willing (or with no other option but) to manage JWTs and sessions manually.

Nuts & Bolts

import { PrismaAdapter } from "@auth/prisma-adapter"; import { getServerSession, type DefaultSession, type NextAuthOptions, } from "next-auth"; import { type Adapter } from "next-auth/adapters"; import CredentialsProvider from "next-auth/providers/credentials"; import { db } from "~/server/db"; /** * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` * object and keep type safety. * * @see https://next-auth.js.org/getting-started/typescript#module-augmentation */ declare module "next-auth" { interface Session extends DefaultSession { user: { id: string; // ...other properties // role: UserRole; } & DefaultSession["user"]; } } /** * Options for NextAuth.js used to configure adapters, providers, callbacks, etc. * * @see https://next-auth.js.org/configuration/options */ export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(db) as Adapter, session: { strategy: "jwt", }, providers: [ CredentialsProvider({ // The name to display on the sign in form (e.g. 'Sign in with...') name: "Credentials", // The credentials is used to generate a suitable form on the sign in page. // You can specify whatever fields you are expecting to be submitted. // e.g. domain, username, password, 2FA token, etc. // You can pass any HTML attribute to the <input> tag through the object. credentials: { username: { label: "Username", type: "text", placeholder: "" }, password: { label: "Password", type: "password" }, }, async authorize(credentials, req) { /* You need to provide your own logic here that takes the credentials submitted and returns either a object representing a user or value that is false/null if the credentials are invalid. */ if (!credentials?.username || !credentials?.password) { return null; } const res = await fetch("https://api.tastyworks.com/sessions", { method: "POST", body: JSON.stringify({ login: credentials?.username ?? "", password: credentials?.password ?? "", "remember-me": true, }), headers: { "User-Agent": "tasty-t3-dashboard/1.0", Accept: "application/json", "Content-Type": "application/json", }, }); /* need data.session-token && data.session-expiration for all subsequent tastytrade requests */ const userResponse = (await res.json()) as { data: { user: { email: string; username: string; "external-id": string }; "session-token": string; "session-expiration": string; }; }; if (userResponse?.data) { // Extract user data const externalUser = userResponse.data.user; // Check if user exists in the database let user = await db.user.findUnique({ where: { email: externalUser.email }, }); if (!user) { // Create a new user user = await db.user.create({ data: { name: externalUser.username, email: externalUser.email, id: externalUser["external-id"], }, }); } // save/create the session const session = await db.session.upsert({ where: { userId: externalUser["external-id"] }, update: { sessionToken: userResponse.data["session-token"], expires: new Date(userResponse.data["session-expiration"]), }, create: { userId: externalUser["external-id"], sessionToken: userResponse.data["session-token"], expires: new Date(userResponse.data["session-expiration"]), }, }); return { ...user }; } // Return null if user data could not be retrieved return null; }, }), ], logger: { error(code, ...message) { console.error(code, ...message); }, warn(code, ...message) { console.warn(code, ...message); }, debug(code, ...message) { console.debug(code, ...message); }, }, }; /** * Wrapper for `getServerSession` so that you don't need to import the `authOptions` in every file. * * @see https://next-auth.js.org/configuration/nextjs */ export const getServerAuthSession = () => getServerSession(authOptions);

Our big

authOptions
object is fed to
getServerSession
as a parameter. In
authOptions{}
we define our database adapter (Prisma) and set the session to
jwt
because we’re using the
CredentialsProvider
instead of an out-of-the-box provider so we have to roll our own sessions.

In this example I’m signing into the Tastyworks API which requires a

POST
request be sent to
https://api.tastyworks.com/sessions
with username and password credentials in the body of the request.

In this example it’s imperative that we define the

User-Agent
header with a
<product>/<version>
value (e.g.
"User-Agent": "tasty-t3-dashboard/1.0",
).

We

await
the response and parse the JSON of the
body
whereby if data is present (this means we have an existing user), we search our Prisma db for that user using their email.

If we don’t find a user we create one in our db using the user data from Tastyworks. Next, using Prisma’s

upsert()
, we either update the db session with the JWT from Tastyworks or create a new session related to the user using the Tastyworks JWT.

Last, we return the user like the NextAuth documentation asked (or

null
if no user was found and authentication failed).

Amplify specifics

Amazon gives developers an incredible amount of control but with that comes an incredible amount of Amazon-specific jargon to wade through

  • Amazon Cognito hosts it’s own registration and authentication UI/server

  • Your AppSync app is redirected to the hosted UI where it handles user flows

  • Amplify automatically signs requests with short term credentials that expire, rotate, and refresh using the Amplify client libraries

  • Cognito user pools - user directory that handles registration, authentication, and account recovery (password reset/lost username)

    • can sign in through Cognito or federate through a third-party IdP
    • built-in, customizable UI to sign in/register users
  • Cognito Identity pools - user obtains temporary AWS credentials to access AWS services

  • IAM (AWS Identity and Access Management) - service to help control access to AWS resources internally ‘A primary use for IAM users is to give people the ability to sign in to the AWS Management Console for interactive tasks and to make programmatic requests to AWS services using the API or CLI.’

  • owner vs group access control -

Nuts and bolts

  1. User enters username and password into UI and submits
  2. App calls
    InitiateAuth
    with the username and Secure Remote Password (SRP) details
  • SRP is generated with Cognito AWS SDK
  1. App calls
    RespondtoAuthChallege
    and returns ID, access, and refresh tokens to start a session and complete the auth flow if it succeeds, otherwise it calls another
    RespondtoAuthChallege
    .
    • ID token - a JSON web token that contains data about the authenticated users such as name, email, and phone number that can be used inside the application
    • access token - a JWT that contains data about the user, a list of the user’s groups, and scopes. The access token authorizes API operations for the user
    • Lambda triggers - Cognito supports Lambda functions to handle events during registration and authentication
import { ApolloProvider } from "@apollo/client"; import { Auth } from "aws-amplify"; import { withAuthenticator } from "aws-amplify-react-native"; import AWSAppSyncClient, { AUTH_TYPE } from "aws-appsync"; import React from "react"; import awsconfig from "./src/aws-exports"; const client = new AWSAppSyncClient({ url: awsconfig.aws_appsync_graphqlEndpoint, region: awsconfig.aws_appsync_region, auth: { type: "API_KEY", apiKey: awsconfig.aws_appsync_apiKey, }, disableOffline: true, }); Auth.configure(awsconfig); const App = () => { return { <ApolloProvider client={client}> <RestOfApp /> </ApolloProvider> } } export default withAuthenticator(App);

The

ApolloProvider
hook uses React’s Context API to make the client hook up to Amplify’s GraphQL endpoint.
Auth
loads your auto-generated AWS configuration file.
withAuthenticator
is a higher order component that wraps the app and detects authentication state.