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 params on graphQL
@auth
@model
- in Kinde, use hook to access permissions for the current user
getKindeServerSession()
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)
- secretly intercepting and altering traffic
- 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 element with an
<form>
id
- Use in
for
to enable larger touch area for input focus<label>
- on
autocomplete
elements for better password manager integration<input>
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
app.use(express.json())
Then we set up the local database using our
auth.json
rpId
protocol
port
exprectedOrigin
Finally, we define the login and register routes (
app.post(“/auth/login”, (req, res) => ...
findUser
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
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
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
getServerSession
authOptions{}
jwt
CredentialsProvider
In this example I’m signing into the Tastyworks API which requires a
POST
https://api.tastyworks.com/sessions
In this example it’s imperative that we define the
User-Agent
<product>/<version>
"User-Agent": "tasty-t3-dashboard/1.0",
We
await
body
If we don’t find a user we create one in our db using the user data from Tastyworks. Next, using Prisma’s
upsert()
Last, we return the user like the NextAuth documentation asked (or
null
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
- User enters username and password into UI and submits
- App calls with the username and Secure Remote Password (SRP) details
InitiateAuth
- SRP is generated with Cognito AWS SDK
- App calls and returns ID, access, and refresh tokens to start a session and complete the auth flow if it succeeds, otherwise it calls another
RespondtoAuthChallege
.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
Auth
withAuthenticator