diff options
Diffstat (limited to 'server.js')
| -rw-r--r-- | server.js | 107 |
1 files changed, 107 insertions, 0 deletions
diff --git a/server.js b/server.js new file mode 100644 index 0000000..5b373da --- /dev/null +++ b/server.js | |||
| @@ -0,0 +1,107 @@ | |||
| 1 | const express = require("express"); | ||
| 2 | const crypto = require("crypto"); | ||
| 3 | const querystring = require("querystring"); | ||
| 4 | const jwt = require("jsonwebtoken"); | ||
| 5 | const jwksClient = require("jwks-rsa"); | ||
| 6 | |||
| 7 | const app = express(); | ||
| 8 | const CLIENT_ID = "ec014b23-edde-4a09-9a41-36bff5630829"; // appID | ||
| 9 | const CLIENT_SECRET = "Y.Q8Q~HuoEbCsICK18eG4oAtqjMe5eGyWSilLaZI"; // appid secret | ||
| 10 | const TENANT = "publicanub.onmicrosoft.com"; | ||
| 11 | const TENANT_ID = "23c95e59-28bd-472a-bbd4-4e310dd8f031"; // organisation id | ||
| 12 | const REDIRECT_URI = "http://localhost:3000/callback"; | ||
| 13 | const AUTH_URL = `https://login.microsoftonline.com/${TENANT}/oauth2/v2.0`; | ||
| 14 | const SCOPES = "openid profile email"; | ||
| 15 | |||
| 16 | // Fetch Microsoft's public key by kid to verify JWT signatures | ||
| 17 | const keys = jwksClient({ jwksUri: "https://login.microsoftonline.com/common/discovery/v2.0/keys" }); | ||
| 18 | function getKey(header, cb) { | ||
| 19 | keys.getSigningKey(header.kid, (err, key) => cb(err, key?.getPublicKey())); | ||
| 20 | } | ||
| 21 | |||
| 22 | function verifyJwt(token) { | ||
| 23 | return new Promise((resolve, reject) => { | ||
| 24 | jwt.verify(token, getKey, { | ||
| 25 | audience: CLIENT_ID, | ||
| 26 | // Multi-tenant: accept tokens from any Azure AD tenant | ||
| 27 | issuer: (iss) => iss.startsWith("https://login.microsoftonline.com/") && iss.endsWith("/v2.0"), | ||
| 28 | algorithms: ["RS256"], | ||
| 29 | }, (err, decoded) => err ? reject(err) : resolve(decoded)); | ||
| 30 | }); | ||
| 31 | } | ||
| 32 | |||
| 33 | // POST to Microsoft's token endpoint | ||
| 34 | function fetchTokens(code) { | ||
| 35 | return new Promise((resolve, reject) => { | ||
| 36 | const body = querystring.stringify({ | ||
| 37 | client_id: CLIENT_ID, client_secret: CLIENT_SECRET, | ||
| 38 | grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, scope: SCOPES, | ||
| 39 | }); | ||
| 40 | const req = require("https").request({ | ||
| 41 | hostname: "login.microsoftonline.com", | ||
| 42 | path: `/${TENANT}/oauth2/v2.0/token`, | ||
| 43 | method: "POST", | ||
| 44 | headers: { "Content-Type": "application/x-www-form-urlencoded", "Content-Length": Buffer.byteLength(body) }, | ||
| 45 | }, (res) => { | ||
| 46 | let data = ""; | ||
| 47 | res.on("data", (c) => data += c); | ||
| 48 | res.on("end", () => resolve(JSON.parse(data))); | ||
| 49 | }); | ||
| 50 | req.on("error", reject); | ||
| 51 | req.write(body); | ||
| 52 | req.end(); | ||
| 53 | }); | ||
| 54 | } | ||
| 55 | |||
| 56 | app.get("/", (_, res) => res.send('<h1>OIDC Demo</h1><a href="/login">Sign In with Azure AD</a>')); | ||
| 57 | |||
| 58 | app.get("/login", (_, res) => { | ||
| 59 | const params = querystring.stringify({ | ||
| 60 | client_id: CLIENT_ID, response_type: "code", redirect_uri: REDIRECT_URI, | ||
| 61 | scope: SCOPES, state: crypto.randomBytes(16).toString("hex"), | ||
| 62 | nonce: crypto.randomBytes(16).toString("hex"), response_mode: "query", | ||
| 63 | }); | ||
| 64 | res.redirect(`${AUTH_URL}/authorize?${params}`); | ||
| 65 | }); | ||
| 66 | |||
| 67 | // Microsoft redirects here with ?code=<authorization_code>&state=<state> | ||
| 68 | // The code is a one-time-use temporary key that we exchange + client_secret for JWT tokens | ||
| 69 | app.get("/callback", async (req, res) => { | ||
| 70 | const { code, error, error_description } = req.query; | ||
| 71 | if (error) return res.send(`<pre>Error: ${error}: ${error_description}</pre>`); | ||
| 72 | |||
| 73 | try { | ||
| 74 | const tokens = await fetchTokens(code); | ||
| 75 | if (tokens.error) return res.send(`<pre>Token error: ${JSON.stringify(tokens, null, 2)}</pre><a href="/">Home</a>`); | ||
| 76 | |||
| 77 | // Verify JWT signature against Microsoft's public key + check aud, iss, exp | ||
| 78 | const v = await verifyJwt(tokens.id_token); | ||
| 79 | |||
| 80 | // // Only allow specific tenants | ||
| 81 | // const ALLOWED_TENANTS = ["23c95e59-28bd-472a-bbd4-4e310dd8f031"]; | ||
| 82 | // if (!ALLOWED_TENANTS.includes(v.tid)) { | ||
| 83 | // return res.status(403).send(`<pre>Tenant not allowed: ${v.tid}</pre><a href="/">Home</a>`); | ||
| 84 | // } | ||
| 85 | |||
| 86 | res.send(` | ||
| 87 | <style>body{font-family:monospace;font-size:12px;padding:10px}h1{font-size:16px}h2{font-size:13px;margin-top:14px}pre{font-size:11px;background:#f4f4f4;padding:8px;overflow:auto}</style> | ||
| 88 | <h1>JWT Verified</h1> | ||
| 89 | <h2>Callback received (req.query)</h2> | ||
| 90 | <pre>${JSON.stringify(req.query, null, 2)}</pre> | ||
| 91 | <p>This code was exchanged + client_secret for the tokens below</p> | ||
| 92 | <h2>Checks</h2> | ||
| 93 | <p>Signature: VALID (RS256, Microsoft public key)</p> | ||
| 94 | <p>Audience: ${v.aud} | Issuer: ${v.iss} (tenant: ${v.tid})</p> | ||
| 95 | <p>Expires: ${new Date(v.exp * 1000).toISOString()}</p> | ||
| 96 | <h2>User</h2> | ||
| 97 | <p>oid: ${v.oid} | ${v.name} | ${v.preferred_username}</p> | ||
| 98 | <h2>Claims</h2> | ||
| 99 | <pre>${JSON.stringify(v, null, 2)}</pre> | ||
| 100 | <a href="/">Start over</a> | ||
| 101 | `); | ||
| 102 | } catch (err) { | ||
| 103 | res.status(401).send(`<pre>JWT verification failed: ${err.message}</pre><a href="/">Try again</a>`); | ||
| 104 | } | ||
| 105 | }); | ||
| 106 | |||
| 107 | app.listen(3000, () => console.log("http://localhost:3000")); | ||
