From 7397640ce4c5d493f9d3b8ebf6cce5f15936b394 Mon Sep 17 00:00:00 2001 From: Kejmila Date: Fri, 22 May 2026 07:02:59 +0200 Subject: [PATCH] add tutorial-silent-auth-backend and update verify backend steps --- .../astro.config.mjs | 28 ++ .../tutorial-silent-auth-backend/package.json | 16 + .../scripts/start.sh | 47 +++ .../src/components/Footer.astro | 8 + .../src/components/StepNavigation.astro | 144 +++++++ .../src/content.config.ts | 8 + .../src/content/docs/01-backend-init.md | 79 ++++ .../content/docs/02-backend-minimal-server.md | 83 ++++ .../src/content/docs/03-vonage-setup.md | 101 +++++ .../src/content/docs/04-backend-vonage-sdk.md | 103 +++++ .../src/content/docs/05-backend-store.md | 130 +++++++ .../docs/06-backend-start-verification.md | 161 ++++++++ .../src/content/docs/07-backend-check-code.md | 95 +++++ .../content/docs/08-backend-callback-next.md | 355 ++++++++++++++++++ .../src/content/docs/index.md | 71 ++++ .../tutorial.config.yaml | 43 +++ .../workspace/README.md | 3 + .../workspace/package.json | 10 + .../workspace/server.js | 3 + 19 files changed, 1488 insertions(+) create mode 100644 tutorials/tutorial-silent-auth-backend/astro.config.mjs create mode 100644 tutorials/tutorial-silent-auth-backend/package.json create mode 100644 tutorials/tutorial-silent-auth-backend/scripts/start.sh create mode 100644 tutorials/tutorial-silent-auth-backend/src/components/Footer.astro create mode 100644 tutorials/tutorial-silent-auth-backend/src/components/StepNavigation.astro create mode 100644 tutorials/tutorial-silent-auth-backend/src/content.config.ts create mode 100644 tutorials/tutorial-silent-auth-backend/src/content/docs/01-backend-init.md create mode 100644 tutorials/tutorial-silent-auth-backend/src/content/docs/02-backend-minimal-server.md create mode 100644 tutorials/tutorial-silent-auth-backend/src/content/docs/03-vonage-setup.md create mode 100644 tutorials/tutorial-silent-auth-backend/src/content/docs/04-backend-vonage-sdk.md create mode 100644 tutorials/tutorial-silent-auth-backend/src/content/docs/05-backend-store.md create mode 100644 tutorials/tutorial-silent-auth-backend/src/content/docs/06-backend-start-verification.md create mode 100644 tutorials/tutorial-silent-auth-backend/src/content/docs/07-backend-check-code.md create mode 100644 tutorials/tutorial-silent-auth-backend/src/content/docs/08-backend-callback-next.md create mode 100644 tutorials/tutorial-silent-auth-backend/src/content/docs/index.md create mode 100644 tutorials/tutorial-silent-auth-backend/tutorial.config.yaml create mode 100644 tutorials/tutorial-silent-auth-backend/workspace/README.md create mode 100644 tutorials/tutorial-silent-auth-backend/workspace/package.json create mode 100644 tutorials/tutorial-silent-auth-backend/workspace/server.js diff --git a/tutorials/tutorial-silent-auth-backend/astro.config.mjs b/tutorials/tutorial-silent-auth-backend/astro.config.mjs new file mode 100644 index 0000000..560b8d0 --- /dev/null +++ b/tutorials/tutorial-silent-auth-backend/astro.config.mjs @@ -0,0 +1,28 @@ +import { defineConfig } from 'astro/config'; +import starlight from '@astrojs/starlight'; + +export default defineConfig({ + integrations: [ + starlight({ + title: 'silent-auth-tutorial', + social: { + github: 'https://github.com', + }, + // Auto-generate sidebar from content files + sidebar: [ + { + label: 'Tutorial', + autogenerate: { directory: 'docs' } + } + ], + // Override Footer to add step navigation + components: { + Footer: './src/components/Footer.astro', + }, + }), + ], + server: { + port: 1234, + host: true, // Listen on all addresses (0.0.0.0) for Codespaces compatibility + }, +}); diff --git a/tutorials/tutorial-silent-auth-backend/package.json b/tutorials/tutorial-silent-auth-backend/package.json new file mode 100644 index 0000000..aa948f7 --- /dev/null +++ b/tutorials/tutorial-silent-auth-backend/package.json @@ -0,0 +1,16 @@ +{ + "name": "tutorial-site", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@astrojs/starlight": "^0.30.0", + "astro": "^5.0.0" + } +} \ No newline at end of file diff --git a/tutorials/tutorial-silent-auth-backend/scripts/start.sh b/tutorials/tutorial-silent-auth-backend/scripts/start.sh new file mode 100644 index 0000000..f397613 --- /dev/null +++ b/tutorials/tutorial-silent-auth-backend/scripts/start.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# Don't use set -e here since we're running background processes +# We want the script to continue even if one process has issues starting + +# Colors for output +GREEN='\x1b[0;32m' +BLUE='\x1b[0;34m' +YELLOW='\x1b[1;33m' +NC='\x1b[0m' # No Color + +echo -e "$GREENStarting tutorial environment...$NC" +echo "" + +# Function to handle cleanup on exit +cleanup() { + echo "" + echo -e "$YELLOWShutting down servers...$NC" + # Kill all background processes in this process group + kill 0 2>/dev/null || true + exit 0 +} + +trap cleanup SIGINT SIGTERM EXIT + +# Start Astro tutorial server in background +echo -e "$BLUE[Tutorial]$NC Starting Astro server on port 1234..." +(npm run dev -- --host 0.0.0.0 --port 1234 2>&1 | sed "s/^/[Tutorial] /") & +ASTRO_PID=$! + +# Wait a moment for Astro to start +sleep 2 + +# Start learner application server in background +echo -e "$BLUE[Learner]$NC Starting node server in workspace..." +(cd workspace && npm run dev 2>&1 | sed "s/^/[Learner] /") & +LEARNER_PID=$! + +echo "" +echo -e "$GREEN✓ Both servers are running$NC" +echo -e "$GREEN✓ Tutorial available at http://localhost:1234$NC" +echo -e "$GREEN✓ Learner app available at http://localhost:3000$NC" +echo "" +echo "Press Ctrl+C to stop both servers" +echo "" + +# Wait for both processes +wait diff --git a/tutorials/tutorial-silent-auth-backend/src/components/Footer.astro b/tutorials/tutorial-silent-auth-backend/src/components/Footer.astro new file mode 100644 index 0000000..ff60981 --- /dev/null +++ b/tutorials/tutorial-silent-auth-backend/src/components/Footer.astro @@ -0,0 +1,8 @@ +--- +// Override Footer to add step navigation +import Default from '@astrojs/starlight/components/Footer.astro'; +import StepNavigation from './StepNavigation.astro'; +--- + + + diff --git a/tutorials/tutorial-silent-auth-backend/src/components/StepNavigation.astro b/tutorials/tutorial-silent-auth-backend/src/components/StepNavigation.astro new file mode 100644 index 0000000..1633a0d --- /dev/null +++ b/tutorials/tutorial-silent-auth-backend/src/components/StepNavigation.astro @@ -0,0 +1,144 @@ +--- +import { getCollection } from 'astro:content'; + +// Get all docs entries +const allDocs = await getCollection('docs'); +const sortedDocs = allDocs.sort((a, b) => { + // Sort by step number if available, otherwise by slug + const aStep = a.data.step || (a.slug === 'index' ? 0 : 999); + const bStep = b.data.step || (b.slug === 'index' ? 0 : 999); + if (aStep !== bStep) return aStep - bStep; + return a.slug.localeCompare(b.slug); +}); + +// Find current page index +const currentPath = Astro.url.pathname.replace(/^\//, '').replace(/\/$/, '') || 'index'; +const currentIndex = sortedDocs.findIndex((doc) => { + const docSlug = doc.slug === 'index' ? 'index' : doc.slug; + return docSlug === currentPath || `/${docSlug}/` === Astro.url.pathname; +}); + +const prevDoc = currentIndex > 0 ? sortedDocs[currentIndex - 1] : null; +const nextDoc = currentIndex >= 0 && currentIndex < sortedDocs.length - 1 ? sortedDocs[currentIndex + 1] : null; +--- + +{(prevDoc || nextDoc) && ( + +)} + + diff --git a/tutorials/tutorial-silent-auth-backend/src/content.config.ts b/tutorials/tutorial-silent-auth-backend/src/content.config.ts new file mode 100644 index 0000000..4cdd345 --- /dev/null +++ b/tutorials/tutorial-silent-auth-backend/src/content.config.ts @@ -0,0 +1,8 @@ +import { defineCollection } from 'astro:content'; +import { docsSchema } from '@astrojs/starlight/schema'; + +export const collections = { + docs: defineCollection({ + schema: docsSchema(), + }), +}; diff --git a/tutorials/tutorial-silent-auth-backend/src/content/docs/01-backend-init.md b/tutorials/tutorial-silent-auth-backend/src/content/docs/01-backend-init.md new file mode 100644 index 0000000..1bf8bc9 --- /dev/null +++ b/tutorials/tutorial-silent-auth-backend/src/content/docs/01-backend-init.md @@ -0,0 +1,79 @@ +--- +title: "Project Setup" +description: "Create the server folder, initialise npm, install dependencies, and set up .gitignore." +step: 1 +--- + +In this step you'll create the backend project. + +--- + +## Install dependencies + +Open a terminal in **GitHub Codespaces** and install the project dependencies: + +```bash +npm install express cors dotenv @vonage/auth @vonage/verify2 +``` + +Here's what each package does: + +| Package | Purpose | +|---------|---------| +| `express` | HTTP web framework — handles routing and middleware | +| `cors` | Allows the Android app (a different origin) to call this API | +| `dotenv` | Loads environment variables from `.env` into `process.env` | +| `@vonage/auth` | Handles JWT-based authentication with the Vonage API | +| `@vonage/verify2` | The Vonage Verify v2 SDK — start verifications, check codes | + +--- + +## Set up `.gitignore` + +Two things must never end up in git: `node_modules` and your credentials. + +Create a `.gitignore` file inside `workspace/` and add the following: + +``` +node_modules +.env +private.key +``` + +Or run these commands directly in the terminal: + +```bash +echo "node_modules" >> .gitignore +echo ".env" >> .gitignore +echo "private.key" >> .gitignore +``` + +--- + +## Your project structure so far + +``` +workspace/ +├── node_modules/ ← installed packages (ignored by git) +├── server.js +├── .gitignore +└── package.json +``` + +--- + +## Checkpoint + +Before moving on, confirm: + +- [ ] `workspace/package.json` exists +- [ ] `workspace/node_modules/` exists and contains `express`, `@vonage/auth`, `@vonage/verify2` +- [ ] `workspace/.gitignore` has `node_modules`, `.env`, and `private.key` + +Run this to confirm the key packages are present: + +```bash +ls node_modules | grep -E "express|vonage|dotenv|cors" +``` + +You should see entries for `express`, `cors`, `dotenv`, `@vonage` (as a scoped folder). diff --git a/tutorials/tutorial-silent-auth-backend/src/content/docs/02-backend-minimal-server.md b/tutorials/tutorial-silent-auth-backend/src/content/docs/02-backend-minimal-server.md new file mode 100644 index 0000000..41b00c5 --- /dev/null +++ b/tutorials/tutorial-silent-auth-backend/src/content/docs/02-backend-minimal-server.md @@ -0,0 +1,83 @@ +--- +title: "A Minimal Server" +description: "Create the first version of server.js with a health endpoint, start it with nodemon, and confirm it's running with cURL." +step: 2 +--- + +Before adding any Vonage logic, let's get a working Express server running. This gives you a fast feedback loop: make a change, save the file, and the server restarts automatically. + +--- + +## How auto-reload works in Codespaces + +**nodemon** is pre-installed in this Codespaces environment. It watches your `server.js` file and restarts the Node.js process every time you save a change. You don't need to install anything or stop/start the server manually during this tutorial — just save and nodemon handles the rest. + +--- + +## Create `server.js` + +Inside the `workspace/` folder, open the file called `server.js` and add the following: + +```js +const express = require("express"); +const cors = require("cors"); + +const app = express(); + +app.use(cors()); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +app.get("/health", (req, res) => { + res.json({ status: "ok" }); +}); + +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log(`Backend listening on port ${PORT}`); +}); +``` + +What this does: + +- `cors()` — allows the Android app to call this API from a different origin +- `express.json()` — parses incoming JSON request bodies +- `GET /health` — a simple endpoint to confirm the server is running +- Port **3000** — the default; can be overridden with a `PORT` environment variable + +--- + +## Test the health endpoint + + +Your project already uses `nodemon` to auto-reload `server.js`, so there is no need to manually restart the process. Save the file. You should see it restart automatically in the terminal: + +``` +[nodemon] restarting due to changes... +[nodemon] starting `node server.js` +Backend listening on port 3000 +``` + +Open a **second terminal** and run: + + +```bash +curl http://localhost:3000/health +``` + +Expected response: + +```json +{ "status": "ok" } +``` + +If you see that response, the server is working correctly. + +--- + +## Checkpoint + +- [ ] `workspace/server.js` exists +- [ ] `nodemon server.js` starts without errors +- [ ] `curl http://localhost:3000/health` returns `{ "status": "ok" }` +- [ ] Saving a change to `server.js` triggers an automatic restart diff --git a/tutorials/tutorial-silent-auth-backend/src/content/docs/03-vonage-setup.md b/tutorials/tutorial-silent-auth-backend/src/content/docs/03-vonage-setup.md new file mode 100644 index 0000000..38487a2 --- /dev/null +++ b/tutorials/tutorial-silent-auth-backend/src/content/docs/03-vonage-setup.md @@ -0,0 +1,101 @@ +--- +title: "Auth: Configure Vonage Credentials" +description: "Create a Vonage Application in the Dashboard, download your private key, and configure the backend with your credentials." +step: 3 +--- + +The Vonage Verify API uses **JWT authentication**. Your backend signs each request with a private key that proves it's allowed to call Vonage. In this step you'll add the credentials for the Vonage Application previously created, and wire your credentials into the backend. + +> **Security rule**: The private key must only live on the backend. It must never be bundled into a mobile app or committed to git. + +--- + +## Step 1: Add the private key to your project + +Upload (or copy) the downloaded `private.key` file into the `workspace/` folder of your project. + +The `.gitignore` you created in the previous step already excludes `private.key` from git. Double-check: + +```bash +cat .gitignore +``` + +You should see `private.key` listed. If it isn't, add it: + +```bash +echo "private.key" >> .gitignore +``` + +--- + +## Step 2: Create the `.env` file + +Create a file called `.env` inside `workspace/` and add the following, replacing the placeholder values with your own: + +``` +VONAGE_APP_ID=your_application_id_here +VONAGE_PRIVATE_KEY=./private.key +``` + +- `VONAGE_APP_ID` — the Application ID you copied from the Dashboard +- `VONAGE_PRIVATE_KEY` — a relative path to the private key file; `./private.key` works if both files are in `workspace/` + +> Using a relative path like `./private.key` keeps the project portable — it works on any machine regardless of the absolute path. + +--- + +## Step 3: Confirm credentials load at startup + +Open `server.js` and add the following at the top of the file: + +```js +require("dotenv").config(); + +const cors = require("cors"); +``` + +The `require("dotenv").config()` call at the top of `server.js` loads the `.env` file automatically. Let's add a quick startup log to confirm the credentials are present. + +Update the `app.listen` block at the bottom to log the Application ID: + +```js +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log(`Backend listening on port ${PORT}`); + console.log(`Vonage App ID: ${process.env.VONAGE_APP_ID}`); +}); +``` + +Save the file. nodemon restarts automatically. Check the terminal, you should see your Application ID printed: + +``` +Backend listening on port 3000 +Vonage App ID: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +``` + +If you see `Vonage App ID: undefined`, the `.env` file is in the wrong location or has a typo. Make sure it's inside the `workspace/` folder and that the variable names match exactly. + +Once confirmed, you can remove the `console.log` for the App ID (it's not a secret, but there's no need to log it in production). + +--- + +## Your project structure so far + +``` +workspace/ +├── node_modules/ +├── .env ← credentials (ignored by git) +├── .gitignore +├── package.json +├── private.key ← JWT signing key (ignored by git) +└── server.js +``` + +--- + +## Checkpoint + +- [ ] Vonage Application created in the Dashboard with Network Registry enabled +- [ ] `private.key` is in `workspace/` and listed in `.gitignore` +- [ ] `worspace/.env` has `VONAGE_APP_ID` and `VONAGE_PRIVATE_KEY` +- [ ] `nodemon server.js` starts and shows the correct Application ID in the logs diff --git a/tutorials/tutorial-silent-auth-backend/src/content/docs/04-backend-vonage-sdk.md b/tutorials/tutorial-silent-auth-backend/src/content/docs/04-backend-vonage-sdk.md new file mode 100644 index 0000000..b9f258a --- /dev/null +++ b/tutorials/tutorial-silent-auth-backend/src/content/docs/04-backend-vonage-sdk.md @@ -0,0 +1,103 @@ +--- +title: "Add the Vonage SDK" +description: "Initialise the Vonage Auth and Verify clients in server.js and understand how JWT authentication works." +step: 4 +--- + +Now that credentials are in place, let's connect the Vonage SDK to `server.js`. This is the piece that lets your backend start verifications and validate codes. + +--- + +## Why use the SDK instead of raw HTTP? + +You could call the Vonage API directly with `fetch` or `axios`. The SDK is preferred for a few reasons: + +- **Authentication is handled for you** — the Verify API requires JWT tokens signed with your private key. Getting that wrong is a common source of bugs. The SDK does it correctly every time. +- **Cleaner code** — instead of building URLs, headers, and parsing response shapes manually, you call `newRequest()` and `checkCode()`. +- **Easier maintenance** — when Vonage changes the API or adds features, an SDK update keeps you compatible. + +--- + +## Update `server.js` + +Open `server.js` and add the SDK imports and client setup near the top, after `require("dotenv").config()`: + +```js +require("dotenv").config(); + +const express = require("express"); +const cors = require("cors"); + +const { Auth } = require("@vonage/auth"); +const { Verify2 } = require("@vonage/verify2"); + +const app = express(); + +app.use(cors()); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +// Vonage auth +const credentials = new Auth({ + applicationId: process.env.VONAGE_APP_ID, + privateKey: process.env.VONAGE_PRIVATE_KEY, +}); + +const verifyClient = new Verify2(credentials); + +app.get("/health", (req, res) => { + res.json({ status: "ok" }); +}); + +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log(`Backend listening on port ${PORT}`); +}); +``` + +### What's happening here? + +**`Auth`** creates a credential object that reads your Application ID and private key from the environment. Internally, when you make an API call, it generates a signed JWT and attaches it to the request header automatically — you never touch the JWT directly. + +**`Verify2`** is the Vonage Verify v2 client. You'll use two methods from it: + +| Method | What it does | +|--------|-------------| +| `verifyClient.newRequest(...)` | Starts a new verification (silent auth → SMS) | +| `verifyClient.checkCode(requestId, code)` | Validates the code the user entered | +| `verifyClient.nextWorkflow(requestId)` | Skips the current channel and moves to the next one (used to force SMS fallback) | + +--- + +## Confirm the server still starts cleanly + +Save `server.js`. nodemon restarts automatically. Check the terminal — you should see no errors: + +``` +[nodemon] restarting due to changes... +[nodemon] starting `node server.js` +Backend listening on port 3000 +``` + +If you see an error like `Cannot find module '@vonage/auth'`, make sure you ran `npm install` from inside the `workspace/` folder. + +Test the health endpoint to confirm everything is still working: + +```bash +curl http://localhost:3000/health +``` + +Expected response: + +```json +{ "status": "ok" } +``` + +--- + +## Checkpoint + +- [ ] `server.js` imports `Auth` and `Verify2` +- [ ] `credentials` and `verifyClient` are initialised at the top level +- [ ] Server starts without errors after saving +- [ ] `GET /health` still returns `{ "status": "ok" }` diff --git a/tutorials/tutorial-silent-auth-backend/src/content/docs/05-backend-store.md b/tutorials/tutorial-silent-auth-backend/src/content/docs/05-backend-store.md new file mode 100644 index 0000000..ac287fd --- /dev/null +++ b/tutorials/tutorial-silent-auth-backend/src/content/docs/05-backend-store.md @@ -0,0 +1,130 @@ +--- +title: "The Verification Store" +description: "Understand why the backend needs to track verification state, then add a simple in-memory Map to do it." +step: 5 +--- + +Before you add any endpoints, it's worth understanding *why* the backend needs to remember what's happening with each verification request. This will make the code you write in the next few steps much easier to reason about. + +--- + +## Why we need state + +A verification flow isn't a single request-response pair. It has a **lifecycle**: + +``` +started → pending (silent auth) → completed + ↘ sms pending → completed + ↘ failed / expired +``` + +Three things make tracking state necessary: + +### 1. Vonage callbacks arrive asynchronously + +When you start a verification, Vonage does work in the background. It coordinates with the mobile carrier, sends SMS, waits for outcomes. It notifies your backend by calling your `/callback` endpoint (a webhook) at some future point in time. + +The HTTP request that *started* the verification is long gone by then. Without a store, you have no way to connect the callback to the original request. + +### 2. Webhooks can be delivered more than once + +Vonage may retry a webhook delivery if your backend doesn't respond quickly enough. If you process the same callback twice (for example, marking a user verified twice) your application state becomes inconsistent. With a store, re-applying the same status to the same `request_id` is safe and harmless. + +### 3. Debugging requires visibility + +When something goes wrong ("the user got an SMS but verification never completed"), you need to answer: what was the last known status? What event did the callback send? A store gives you a single place to look. + +--- + +## The store shape + +Each entry in the store is keyed by `request_id` (the unique identifier Vonage returns when you start a verification). The value is a plain object: + +```js +{ + requestId: "aaa-bbb-ccc-ddd", + phone: "+34600111222", + status: "pending", // set by Vonage callbacks + createdAt: "2026-01-01T10:00:00.000Z", + updatedAt: "2026-01-01T10:00:05.000Z", + lastEvent: null // the raw body of the most recent callback +} +``` + +| Field | Description | +|-------|-------------| +| `requestId` | Vonage's unique ID for this verification attempt | +| `phone` | The number being verified | +| `status` | Latest status received from Vonage (e.g. `"pending"`, `"completed"`, `"failed"`) | +| `createdAt` | When the verification was started | +| `updatedAt` | When the entry was last modified | +| `lastEvent` | The raw body of the last callback received — useful for debugging | + +--- + +## Add the store to `server.js` + +Open `server.js` and add the `verificationStore` Map after the `verifyClient` setup and before the first endpoint: + +```js +require("dotenv").config(); + +const express = require("express"); +const cors = require("cors"); + +const { Auth } = require("@vonage/auth"); +const { Verify2 } = require("@vonage/verify2"); + +const app = express(); + +app.use(cors()); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +const credentials = new Auth({ + applicationId: process.env.VONAGE_APP_ID, + privateKey: process.env.VONAGE_PRIVATE_KEY, +}); + +const verifyClient = new Verify2(credentials); + +/** + * In-memory store for verification requests. + * Maps request_id -> { requestId, phone, status, createdAt, updatedAt, lastEvent } + * + * Replace with Redis or a database for production use. + */ +const verificationStore = new Map(); + +app.get("/health", (req, res) => { + res.json({ status: "ok" }); +}); + +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log(`Backend listening on port ${PORT}`); +}); +``` + +### Note on production use + +A JavaScript `Map` lives in memory. It works perfectly for this tutorial but has two limitations in production: + +- **It resets when the server restarts** — any in-progress verifications are lost +- **It doesn't scale across multiple server instances** — each process has its own memory + +In a production system you'd replace the `Map` with **Redis** or a database (Postgres, MongoDB, etc.). The important thing is that the rest of the code that reads and writes to the store doesn't need to change — only the store implementation does. + +--- + +## Checkpoint + +- [ ] `verificationStore` is declared as `new Map()` in `server.js` +- [ ] It's positioned after the Vonage client setup and before the endpoints +- [ ] Server still starts cleanly after saving + +```bash +curl http://localhost:3000/health +``` + +Should still return `{ "status": "ok" }`. diff --git a/tutorials/tutorial-silent-auth-backend/src/content/docs/06-backend-start-verification.md b/tutorials/tutorial-silent-auth-backend/src/content/docs/06-backend-start-verification.md new file mode 100644 index 0000000..5fc2697 --- /dev/null +++ b/tutorials/tutorial-silent-auth-backend/src/content/docs/06-backend-start-verification.md @@ -0,0 +1,161 @@ +--- +title: "The /verification Endpoint" +description: "Add the POST /verification endpoint that starts a Vonage Verify request, stores the entry, and returns request_id and check_url to the client." +step: 6 +--- + +This is the first endpoint the Android app will call. The user enters their phone number, the app sends it here, and this endpoint asks Vonage to start a verification. + +--- + +## What this endpoint does + +1. Receives a `phone` number from the request body +2. Calls `verifyClient.newRequest()` with a two-step workflow: try **Silent Authentication** first, fall back to **SMS** +3. Saves the initial entry to `verificationStore` +4. Returns `request_id` and `check_url` to the client + +The `check_url` is only present when Vonage can attempt Silent Authentication for this phone number and network. The Android app uses it to make the cellular request that proves the user's identity. If it's `null`, the app skips Silent Auth and goes straight to SMS. + +--- + +## Add a helper function + +First, add a small helper that validates required fields in request bodies. Add it just below the `verificationStore` declaration: + +```js +function requireFields(obj, fields) { + for (const f of fields) { + if (!obj || obj[f] == null || obj[f] === "") return f; + } + return null; +} +``` + +This returns the name of the first missing field, or `null` if all fields are present. You'll use it in every endpoint. + +--- + +## Add `POST /verification` to `server.js` + +Open `server.js` and add the endpoint before the `app.listen` call: + +```js +app.post("/verification", async (req, res) => { + try { + const missing = requireFields(req.body, ["phone"]); + if (missing) { + return res.status(400).json({ error: `Field '${missing}' is required.` }); + } + + const { phone } = req.body; + + console.log("Received verification request for:", phone); + + const result = await verifyClient.newRequest({ + brand: "DemoApp", + workflow: [ + { channel: "silent_auth", to: phone }, + { channel: "sms", to: phone }, + ], + }); + + console.log("Vonage newRequest result:", result); + + const now = new Date().toISOString(); + verificationStore.set(result.requestId, { + requestId: result.requestId, + phone, + status: "pending", + createdAt: now, + updatedAt: now, + lastEvent: null, + }); + + return res.json({ + request_id: result.requestId, + check_url: result.checkUrl || null, + }); + } catch (error) { + const status = error?.response?.status || 500; + const details = error?.response?.data || error?.message; + + console.error("Error /verification:", details); + return res.status(status).json({ + error: "Failed to start verification", + details: typeof details === "string" ? details : undefined, + }); + } +}); +``` + +### Understanding the workflow array + +```js +workflow: [ + { channel: "silent_auth", to: phone }, + { channel: "sms", to: phone }, +] +``` + +Vonage processes channels in order. It tries Silent Authentication first. If it doesn't complete within the timeout (about 20 seconds), Vonage automatically starts the SMS step. Your backend can also trigger the SMS step immediately by calling `/next` (you'll add that endpoint in step 08). + +--- + +## Test with cURL + +Save `server.js` (`nodemon` restarts), then test the endpoint. Replace the phone number with a real number in E.164 format: + +```bash +curl -X POST http://localhost:3000/verification \ + -H "Content-Type: application/json" \ + -d '{"phone": "+34600000000"}' +``` + +Expected response: + +```json +{ + "request_id": "aaa-bbb-ccc-ddd", + "check_url": "https://api.nexmo.com/v2/verify/aaa-bbb-ccc-ddd/silent-auth/redirect" +} +``` + +- `request_id` is always present — keep this value, you'll need it for the next test +- `check_url` is present when Silent Auth is available for the number/carrier; it may be `null` + +### Test validation + +Try sending a request without the `phone` field: + +```bash +curl -X POST http://localhost:3000/verification \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +Expected response: + +```json +{ "error": "Field 'phone' is required." } +``` + +--- + +## Check the server logs + +In the nodemon terminal you should see: + +``` +Received verification request for: +34600000000 +Vonage newRequest result: { requestId: 'aaa-bbb-ccc-ddd', checkUrl: 'https://...' } +``` + +--- + +## Checkpoint + +- [ ] `POST /verification` with a valid phone returns `request_id` and `check_url` +- [ ] `POST /verification` without `phone` returns a 400 error +- [ ] The server logs show the Vonage result +- [ ] A real phone number receives an SMS (if Silent Auth isn't available for that number) diff --git a/tutorials/tutorial-silent-auth-backend/src/content/docs/07-backend-check-code.md b/tutorials/tutorial-silent-auth-backend/src/content/docs/07-backend-check-code.md new file mode 100644 index 0000000..3fa4f35 --- /dev/null +++ b/tutorials/tutorial-silent-auth-backend/src/content/docs/07-backend-check-code.md @@ -0,0 +1,95 @@ +--- +title: "The /check-code Endpoint" +description: "Add the POST /check-code endpoint that validates the OTP code with Vonage and returns a verified result to the client." +step: 7 +--- + +Once a user receives a code, either from Silent Authentication or SMS, the Android app sends it to this endpoint. The backend passes it to Vonage, which confirms whether it's valid, and returns a clear `verified` result to the client. + +--- + +## What this endpoint does + +1. Receives `request_id` and `code` from the request body +2. Looks up the entry in `verificationStore` (returns 404 if not found) +3. Calls `verifyClient.checkCode(request_id, code)` +4. Updates the store status to `"completed"` if verified +5. Returns `{ verified: true/false }` to the client + +--- + +## Add `POST /check-code` to `server.js` + +Open `server.js` and add this endpoint after `/verification`: + +```js +app.post("/check-code", async (req, res) => { + try { + const missing = requireFields(req.body, ["request_id", "code"]); + if (missing) { + return res.status(400).json({ error: `Field '${missing}' is required.` }); + } + + const { request_id, code } = req.body; + + const entry = verificationStore.get(request_id); + if (!entry) { + return res.status(404).json({ error: "Unknown request_id" }); + } + + console.log("Checking code for request:", request_id); + + const result = await verifyClient.checkCode(request_id, code); + console.log("Vonage checkCode result:", result); + + const verified = result === "completed"; + + if (verified) { + verificationStore.set(request_id, { + ...entry, + status: "completed", + updatedAt: new Date().toISOString(), + lastEvent: { source: "check_code", result }, + }); + } + + return res.json({ + verified, + status: result || null, + }); + } catch (error) { + const status = error?.response?.status || 500; + const details = error?.response?.data || error?.message; + + console.error("Error /check-code:", details); + + // If it's an invalid code error, return 200 with verified: false + if (status === 400 || status === 404) { + return res.json({ + verified: false, + error: typeof details === "string" ? details : "Invalid code", + }); + } + + return res.status(status).json({ + error: "Failed to check code", + details: typeof details === "string" ? details : undefined, + }); + } +}); +``` + +### Key detail: invalid codes return 200 + +When a user types the wrong code, Vonage returns a `400` response. This is a normal user-facing error, not a server failure. The endpoint catches that case and returns `{ verified: false }` with a 200 status so the Android app can display a friendly "invalid code" message rather than treating it as a crash. + + +--- + +## Checkpoint + +- [ ] `POST /check-code` with a valid code returns `{ verified: true }` +- [ ] `POST /check-code` with an invalid code returns `{ verified: false }` +- [ ] `POST /check-code` without required fields returns a 400 error +- [ ] `POST /check-code` with an unknown `request_id` returns a 404 error +- [ ] The store entry `status` updates to `"completed"` after success diff --git a/tutorials/tutorial-silent-auth-backend/src/content/docs/08-backend-callback-next.md b/tutorials/tutorial-silent-auth-backend/src/content/docs/08-backend-callback-next.md new file mode 100644 index 0000000..4872bc5 --- /dev/null +++ b/tutorials/tutorial-silent-auth-backend/src/content/docs/08-backend-callback-next.md @@ -0,0 +1,355 @@ +--- +title: "Callback and Next Workflow" +description: "Add the /callback webhook that receives status updates from Vonage, the /next endpoint that forces SMS fallback, and configure the callback URL in the Vonage Dashboard." +step: 8 +--- + +Two more endpoints complete the backend: + +- **`/callback`** — Vonage calls this webhook when verification status changes (e.g. Silent Auth completed, or the request expired) +- **`/next`** — the Android app calls this to skip Silent Auth and go straight to SMS, avoiding a ~20-second wait + +--- + +## Add `POST /callback` + +A **callback** (also called a webhook) is a URL in your backend that an external service calls to push notifications to you. Rather than your backend asking Vonage "has anything changed?" over and over, Vonage calls you when something happens. + +Open `server.js` and add the `/callback` endpoint: + +```js +app.post("/callback", async (req, res) => { + try { + const { request_id, status } = req.body || {}; + + if (!request_id) { + return res.status(400).json({ error: "Missing request_id" }); + } + + console.log("Callback received:", { request_id, status }); + + const entry = verificationStore.get(request_id); + if (!entry) { + // Vonage may send callbacks for requests we don't have in memory + // (e.g. after a server restart). Acknowledge and move on. + console.warn("Callback for unknown request_id:", request_id); + return res.status(200).json({ ok: true }); + } + + // Update status from the callback + verificationStore.set(request_id, { + ...entry, + status: status || entry.status, + updatedAt: new Date().toISOString(), + lastEvent: req.body, + }); + + console.log(`Callback updated: ${request_id} -> ${status}`); + + return res.status(200).json({ ok: true }); + } catch (error) { + console.error("Error processing callback:", error); + return res.status(500).json({ error: "Internal server error" }); + } +}); +``` + +### Why always return 200? + +Vonage expects a `200` response from your callback. If it gets anything else — or no response — it retries the delivery. Retries are fine (the update is idempotent: setting the same status again is harmless), but returning a non-200 will cause Vonage to keep retrying unnecessarily. + +--- + +## Add `POST /next` + +The `/next` endpoint tells Vonage to skip the current workflow channel and move to the next one. In our case, that means skipping Silent Auth and sending an SMS immediately. + +This is useful in the Android app when the Silent Auth request fails (bad network, SDK error, etc.) — instead of waiting ~20 seconds for Vonage to time out naturally, the app calls `/next` and the user gets an SMS right away. + +```js +app.post("/next", async (req, res) => { + try { + const missing = requireFields(req.body, ["requestId"]); + if (missing) { + return res.status(400).json({ error: `Field '${missing}' is required.` }); + } + + const { requestId } = req.body; + + const entry = verificationStore.get(requestId); + if (!entry) { + return res.status(404).json({ error: "Unknown request_id" }); + } + + console.log("Moving to next workflow (SMS) for:", requestId); + + const result = await verifyClient.nextWorkflow(requestId); + console.log("Vonage nextWorkflow result:", result); + + verificationStore.set(requestId, { + ...entry, + updatedAt: new Date().toISOString(), + lastEvent: { source: "next_workflow" }, + }); + + return res.status(200).json({ ok: true }); + } catch (error) { + const status = error?.response?.status || 500; + const details = error?.response?.data || error?.message; + + console.error("Error /next:", details); + return res.status(status).json({ + error: "Failed to move workflow", + details: typeof details === "string" ? details : undefined, + }); + } +}); +``` + +> **Note**: If `/next` fails, it's not fatal. Vonage will automatically fall back to SMS after the Silent Auth timeout. The Android app should show the SMS input screen regardless of whether this call succeeds. + +--- + +## The complete `server.js` + +At this point your full `server.js` should look like this: + +```js +require("dotenv").config(); + +const express = require("express"); +const cors = require("cors"); + +const { Auth } = require("@vonage/auth"); +const { Verify2 } = require("@vonage/verify2"); + +const app = express(); + +app.use(cors()); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); + +const credentials = new Auth({ + applicationId: process.env.VONAGE_APP_ID, + privateKey: process.env.VONAGE_PRIVATE_KEY, +}); + +const verifyClient = new Verify2(credentials); + +const verificationStore = new Map(); + +function requireFields(obj, fields) { + for (const f of fields) { + if (!obj || obj[f] == null || obj[f] === "") return f; + } + return null; +} + +app.get("/health", (req, res) => { + res.json({ status: "ok" }); +}); + +app.post("/verification", async (req, res) => { + try { + const missing = requireFields(req.body, ["phone"]); + if (missing) { + return res.status(400).json({ error: `Field '${missing}' is required.` }); + } + const { phone } = req.body; + console.log("Received verification request for:", phone); + const result = await verifyClient.newRequest({ + brand: "DemoApp", + workflow: [ + { channel: "silent_auth", to: phone }, + { channel: "sms", to: phone }, + ], + }); + console.log("Vonage newRequest result:", result); + const now = new Date().toISOString(); + verificationStore.set(result.requestId, { + requestId: result.requestId, + phone, + status: "pending", + createdAt: now, + updatedAt: now, + lastEvent: null, + }); + return res.json({ + request_id: result.requestId, + check_url: result.checkUrl || null, + }); + } catch (error) { + const status = error?.response?.status || 500; + const details = error?.response?.data || error?.message; + console.error("Error /verification:", details); + return res.status(status).json({ + error: "Failed to start verification", + details: typeof details === "string" ? details : undefined, + }); + } +}); + +app.post("/check-code", async (req, res) => { + try { + const missing = requireFields(req.body, ["request_id", "code"]); + if (missing) { + return res.status(400).json({ error: `Field '${missing}' is required.` }); + } + const { request_id, code } = req.body; + const entry = verificationStore.get(request_id); + if (!entry) { + return res.status(404).json({ error: "Unknown request_id" }); + } + console.log("Checking code for request:", request_id); + const result = await verifyClient.checkCode(request_id, code); + console.log("Vonage checkCode result:", result); + const verified = result === "completed"; + if (verified) { + verificationStore.set(request_id, { + ...entry, + status: "completed", + updatedAt: new Date().toISOString(), + lastEvent: { source: "check_code", result }, + }); + } + return res.json({ verified, status: result || null }); + } catch (error) { + const status = error?.response?.status || 500; + const details = error?.response?.data || error?.message; + console.error("Error /check-code:", details); + if (status === 400 || status === 404) { + return res.json({ + verified: false, + error: typeof details === "string" ? details : "Invalid code", + }); + } + return res.status(status).json({ + error: "Failed to check code", + details: typeof details === "string" ? details : undefined, + }); + } +}); + +app.post("/callback", async (req, res) => { + try { + const { request_id, status } = req.body || {}; + if (!request_id) { + return res.status(400).json({ error: "Missing request_id" }); + } + console.log("Callback received:", { request_id, status }); + const entry = verificationStore.get(request_id); + if (!entry) { + console.warn("Callback for unknown request_id:", request_id); + return res.status(200).json({ ok: true }); + } + verificationStore.set(request_id, { + ...entry, + status: status || entry.status, + updatedAt: new Date().toISOString(), + lastEvent: req.body, + }); + console.log(`Callback updated: ${request_id} -> ${status}`); + return res.status(200).json({ ok: true }); + } catch (error) { + console.error("Error processing callback:", error); + return res.status(500).json({ error: "Internal server error" }); + } +}); + +app.post("/next", async (req, res) => { + try { + const missing = requireFields(req.body, ["requestId"]); + if (missing) { + return res.status(400).json({ error: `Field '${missing}' is required.` }); + } + const { requestId } = req.body; + const entry = verificationStore.get(requestId); + if (!entry) { + return res.status(404).json({ error: "Unknown request_id" }); + } + console.log("Moving to next workflow (SMS) for:", requestId); + const result = await verifyClient.nextWorkflow(requestId); + console.log("Vonage nextWorkflow result:", result); + verificationStore.set(requestId, { + ...entry, + updatedAt: new Date().toISOString(), + lastEvent: { source: "next_workflow" }, + }); + return res.status(200).json({ ok: true }); + } catch (error) { + const status = error?.response?.status || 500; + const details = error?.response?.data || error?.message; + console.error("Error /next:", details); + return res.status(status).json({ + error: "Failed to move workflow", + details: typeof details === "string" ? details : undefined, + }); + } +}); + +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log(`Backend listening on port ${PORT}`); +}); +``` + +--- + +## Configure the callback URL in the Vonage Dashboard + +Vonage needs to know where to send callbacks. You need a **publicly accessible URL** — `localhost` isn't reachable from Vonage's servers. + +In Codespaces, you can expose a port publicly: + +1. In VS Code / Codespaces, open the **Ports** tab (bottom panel) +2. Find port `3000` and set its visibility to **Public** +3. Copy the forwarded URL (it looks like `https://your-codespace-name-3000.app.github.dev`) + +Then in the [Vonage Dashboard](https://dashboard.nexmo.com/): + +1. Go to **Applications** and open your application +2. Click **Edit** +3. Under **Network Registry**, find the **Verify** callback field +4. Take the URL you copied in step 3 above and add `/callback` at the end. Your final URL should look like this: `https://your-codespace-name-3000.app.github.dev/callback` +5. Save the application + +**Important**: The URL is unique to your Codespace - do not copy the example above. Always use your own URL from the Ports tab with `/callback` appended. + +### Test the callback manually + +You can simulate a callback with cURL to verify your endpoint is working: + +```bash +curl -X POST http://localhost:3000/callback \ + -H "Content-Type: application/json" \ + -d '{"request_id": "test-id", "status": "completed"}' +``` + +Expected response: + +```json +{ "ok": true } +``` + +The terminal log should show: + +``` +Callback received: { request_id: 'test-id', status: 'completed' } +Callback for unknown request_id: test-id +``` + +(The "unknown" warning is expected here since `test-id` isn't in the store.) + +--- + +## Checkpoint + +- [ ] `POST /callback` returns `{ ok: true }` and logs the received status +- [ ] `POST /next` returns `{ ok: true }` for a valid `requestId` +- [ ] The callback URL is configured in the Vonage Dashboard +- [ ] The backend is fully implemented — all five endpoints are working + +The backend is complete. In the next tutorial, you'll build the Android app that drives this flow from the user's side. + +> Don't close this tutorial as you'll need the backend running to complete the Android app + diff --git a/tutorials/tutorial-silent-auth-backend/src/content/docs/index.md b/tutorials/tutorial-silent-auth-backend/src/content/docs/index.md new file mode 100644 index 0000000..e566caf --- /dev/null +++ b/tutorials/tutorial-silent-auth-backend/src/content/docs/index.md @@ -0,0 +1,71 @@ +--- +title: "What We're Building" +description: "A high-level look at the 2FA with Silent Auth — what it does, how it works, and what you'll build across this tutorial." +--- + +In this tutorial you'll build a complete **two-factor authentication (2FA) system** using [Vonage Verify v2](https://developer.vonage.com/en/verify/overview). It uses **Silent Authentication** as the primary channel — no OTP required when it works — and falls back to **SMS** when it can't. + +By the end you'll have: + +- A **Node.js backend** that manages the entire verification flow +- An **Android app** (Kotlin + Jetpack Compose) that drives the user experience +- A working end-to-end test confirming both paths + +--- + +### Why two channels? + +| Channel | How it works | User experience | +|--------|-------------|-----------------| +| **Silent Authentication** | Vonage checks the phone's mobile network connection — no action from the user | Invisible | +| **SMS OTP** | Vonage sends a one-time code by SMS — user types it in | Familiar fallback | + +--- + +## Design principle: the backend owns everything + +The Android app never calls Vonage directly. It never stores API keys. It only talks to **your** backend, which in turn talks to Vonage. This keeps secrets off the device and puts all business logic in one place. + +``` +Android App ──→ Your Backend ──→ Vonage Verify API + ↑ + Vonage Callback (webhook) +``` + +--- + +## What you'll need + +### Backend (GitHub Codespaces) + +This tutorial runs in **GitHub Codespaces**. The backend terminal, nodemon, Node.js, and npm are all pre-configured — no local installation needed. + +> **nodemon** is already installed in the Codespaces environment. It watches `server.js` and restarts the server automatically every time you save the file. You'll never need to manually stop and restart the server during this tutorial. + +You will need: + +- A [Vonage developer account](https://developer.vonage.com/sign-up) (free tier is fine) +- A phone number in **E.164 format** (e.g. `+34600111222`) to perform the verification + +### Android app (local machine) + +- [Android Studio](https://developer.android.com/studio) (stable channel) +- A real Android device running **Android 7.0+** (API 24 or higher) with a SIM and mobile data + +> **Why a real device?** Silent Authentication relies on the mobile carrier network context. An emulator cannot replicate this. You can still use an emulator to test the SMS path, but Silent Auth will always fall back to SMS on an emulator. + +--- + + +## Tutorial structure + +| # | Page | What you'll do | +|---|------|----------------| +| 01 | Backend: Project Init | Set up environment and install deps | +| 02 | Backend: Minimal Server | First working `server.js`, first cURL test | +| 03 | Vonage Setup | Create Vonage Application, configure credentials | +| 04 | Vonage SDK | Add auth + Verify client | +| 05 | Verification Store | Add in-memory state map | +| 06 | `/verification` | Start a verification request | +| 07 | `/check-code` | Validate a code | +| 08 | `/callback` + `/next` | Webhook + fallback trigger | diff --git a/tutorials/tutorial-silent-auth-backend/tutorial.config.yaml b/tutorials/tutorial-silent-auth-backend/tutorial.config.yaml new file mode 100644 index 0000000..ff5ec96 --- /dev/null +++ b/tutorials/tutorial-silent-auth-backend/tutorial.config.yaml @@ -0,0 +1,43 @@ +version: 1 +tutorial: + id: silent-auth-tutorial + title: silent-auth-tutorial + description: 'Interactive tutorial: silent-auth-tutorial' +content: + format: markdown + docsDir: src/content/docs + indexFile: index.md +runtime: + type: node + workingDirectory: workspace + installCommand: npm install + startCommand: npm run dev +application: + port: 3000 + protocol: http + host: 0.0.0.0 +ports: + - name: app + port: 3000 + protocol: http + visibility: public + description: Main application endpoint +tutorialServer: + enabled: true + port: 1234 + host: 0.0.0.0 + installCommand: npm install + startCommand: npm run dev -- --host 0.0.0.0 --port 1234 + autoInstall: true + autoStart: true +devcontainer: + enabled: true + forwardPorts: + - 3000 + - 1234 + postCreateCommand: cd workspace && npm install +steps: + naming: numeric-prefix + defaultExtension: md +output: + siteDir: dist diff --git a/tutorials/tutorial-silent-auth-backend/workspace/README.md b/tutorials/tutorial-silent-auth-backend/workspace/README.md new file mode 100644 index 0000000..df79dba --- /dev/null +++ b/tutorials/tutorial-silent-auth-backend/workspace/README.md @@ -0,0 +1,3 @@ +# silent-auth-tutorial + +This is your learner project. Follow the tutorial steps to build your application. diff --git a/tutorials/tutorial-silent-auth-backend/workspace/package.json b/tutorials/tutorial-silent-auth-backend/workspace/package.json new file mode 100644 index 0000000..e4f3e9e --- /dev/null +++ b/tutorials/tutorial-silent-auth-backend/workspace/package.json @@ -0,0 +1,10 @@ +{ + "name": "workspace", + "version": "1.0.0", + "scripts": { + "dev": "nodemon server.js" + }, + "devDependencies": { + "nodemon": "^3.1.0" + } +} diff --git a/tutorials/tutorial-silent-auth-backend/workspace/server.js b/tutorials/tutorial-silent-auth-backend/workspace/server.js new file mode 100644 index 0000000..04ed0ac --- /dev/null +++ b/tutorials/tutorial-silent-auth-backend/workspace/server.js @@ -0,0 +1,3 @@ +/** + * Your code goes here + */