এই পর্বে যা শিখবেন
- Express 5-এ
openid-clientদিয়ে Authorization Code + PKCE login বানানো - ID token, discovery আর JWKS বাস্তবে কোথায় কাজে আসে
- Prisma-য়
Accounttable আর নিরাপদ account linking - Redirect URI নিবন্ধন, dev আর production-এর আলাদা ঠিকানা, আর Google-এর setup কথায়
- Auth.js, Better Auth, Lucia আর hosted সেবার সৎ তুলনা, আর Better Auth-এর আসল setup
১ · Provider পরিচয় জানায়, session আপনার
বাজারঘরে এখন password দিয়ে login চলে (পর্ব ৪৫, Session-ভিত্তিক login). কিন্তু ক্রেতারা বলছেন, “আরেকটি password মনে রাখতে পারব না, Google দিয়ে ঢুকতে দিন।” এটিই social login: একটি বাইরের সেবা, যাকে বলে identity provider বা শুধু provider, মানুষটিকে চিনিয়ে দেয়, আর আপনি password রাখা থেকে বেঁচে যান।
তত্ত্ব এখানে আবার শেখাব না। OAuth 2.0-র চার ভূমিকা, কেন OAuth একা login নয়, PKCE-র ধারণা, আর consent phishing পড়ুন Cybersecurity কোর্সের OAuth আর OpenID Connect পর্বে। দুই লাইনে মনে করিয়ে দিই: OAuth অধিকার দেয়, আর OpenID Connect (OIDC) তার ওপরে বলে “মানুষটি কে”। এই পর্বে আমরা সেই flow নিজের অ্যাপে বানাব।
হোটেলের রিসেপশনে আপনি জাতীয় পরিচয়পত্র দেখান। পরিচয়পত্র সরকারের দেওয়া, হোটেল শুধু মিলিয়ে দেখে। তারপর হোটেল আপনাকে নিজের রুম-কার্ড দেয়। সারা সপ্তাহ আপনি রুম-কার্ডেই চলেন, পরিচয়পত্র দেখাতে হয় না। Provider হলো সরকার, ID token হলো পরিচয়পত্র, আর আপনার session cookie হলো রুম-কার্ড।
এই গল্পের সবচেয়ে দরকারি বাক্য: login-এর শেষ ধাপে আপনার অ্যাপ নিজের session বানায়। তারপর পর্ব ৪৫-এর সব নিয়মই চলে: cookie, store, logout. তাই ছবিতে দুটি আলাদা session আছে, আর দুটি আলাদা পথ।
Social login-এর চার পক্ষ আর দুই পথ
ব্রাউজারের পথে শুধু কোড, token আসে সরাসরি পথে
কেন নিজে session বানাবেন, provider-এর token রেখে দিলে হয় না? কারণ তিনটি। Access token আপনার অ্যাপের জন্য বানানো নয়, provider-এর API-র জন্য। ID token-এর মেয়াদ provider ঠিক করে, আপনি নন। আর logout, role, “সব ডিভাইস থেকে বের করুন”, এগুলো আপনার নিজের নিয়ম। Provider একবার পরিচয় জানায়, বাকি জীবন আপনার।
২ · Lab: আসল provider, নকল মানুষ
এই পর্বে Google-এর কোনো আসল account ব্যবহার হয়নি, আর আসল Google-এ login করা এখানে দেখানোও হয়নি। তার বদলে আমরা নিজের মেশিনে একটি সত্যিকারের OIDC server চালিয়েছি: npm-এর oidc-provider. এটি কোনো খেলনা নয়, মান মেনে চলা একটি সম্পূর্ণ implementation. ব্যবহারকারীরা নকল, password-ও নকল, আর সব চলেছে 127.0.0.1-এ। Edge-কে Playwright চালিয়েছে, তাই redirect-গুলো আসল ব্রাউজারে আসল HTTP-তেই ঘটেছে।
এই পর্বের সব “সত্যিকারের run”: Node 24.15.0, Express 5.3.0, express-session 1.19.0, openid-client 6.8.8, oidc-provider 9.13.3, Better Auth 1.7.15, Prisma 7.10.1, TypeScript 7.0.2, Playwright 1.64.1, Edge 156. Database আসল PostgreSQL, চলেছে embedded-postgres 18.6.0-beta.19-এ, যা beta, তাই শুধু শেখার lab-এর জন্য। লেখার সময়ে (নভেম্বর ২০২৬) এগুলো stable. Version বদলাবে, নিজে npm view দিয়ে দেখে নিন। আর যে block-গুলো পাতায় চলে, সেগুলো node:http-এ বানানো শেখার মডেল।
কোডটি (log আর মেয়াদের লাইন বাদে):
// শুধু শেখার lab: নকল ব্যবহারকারী, 127.0.0.1
import Provider from 'oidc-provider';
const users = {
'idp-1001': { email: 'rahim@bazarghar.test', email_verified: true, name: 'Rahim Test' },
'idp-1002': { email: 'karim@bazarghar.test', email_verified: false, name: 'Karim Test' },
'idp-1003': { email: 'salma@bazarghar.test', email_verified: true, name: 'Salma Test' },
'idp-1004': { email: 'nadia@bazarghar.test', email_verified: true, name: 'Nadia Test' },
};
const provider = new Provider('http://localhost:47401', {
clients: [{
client_id: 'bazarghar-local',
client_secret: 'example-client-secret-not-real',
redirect_uris: ['http://localhost:47402/auth/callback'], // পরে আরও একটি
grant_types: ['authorization_code'],
response_types: ['code'],
}],
claims: { openid: ['sub'], email: ['email', 'email_verified'], profile: ['name'] },
findAccount: async (ctx, id) => {
const u = users[id];
if (!u) return undefined;
return { accountId: id, claims: async () => ({ ...u }) };
},
cookies: { keys: ['example-cookie-key-not-real'] },
});
provider.listen(47401, '127.0.0.1');চালু করতেই সে নিজে তিনটি সতর্কবার্তা দিল (সত্যিকারের run, হুবহু). এগুলো পড়ে রাখুন, কারণ production-এ কী কী বদলাতে হয়, তার তালিকা এটিই:
oidc-provider WARNING: a quick start development-only in-memory adapter is used, you MUST change it …
oidc-provider WARNING: quick start development-only signing keys are used, you are expected to provide your own …
oidc-provider WARNING: a quick start development-only feature devInteractions is enabled, you are expected to disable these …
Discovery আর JWKS: হাতে URL লিখতে হয় না
Discovery মানে provider-এর ঠিকানার পরে /.well-known/ বসালে একটি JSON পাওয়া যায়, যাতে সব endpoint লেখা। Library এটি একবার পড়ে নেয়, তাই আপনি শুধু issuer-এর ঠিকানা দেন। আমাদের server-এর আসল উত্তর (ট্রিম করা):
{
"issuer": "http://localhost:47401",
"authorization_endpoint": "http://localhost:47401/auth",
"token_endpoint": "http://localhost:47401/token",
"userinfo_endpoint": "http://localhost:47401/me",
"jwks_uri": "http://localhost:47401/jwks",
"code_challenge_methods_supported": ["S256"],
"id_token_signing_alg_values_supported": ["RS256"],
"authorization_response_iss_parameter_supported": true,
…
}
দেখুন, token endpoint এখানে /token, কিন্তু UserInfo /me, আর authorize /auth. অন্য provider-এ নাম অন্য হবে। হাতে URL লিখলে provider বদলালেই কোড ভাঙে, discovery থাকলে ভাঙে না।
jwks_uri-তে থাকে provider-এর public key. ID token একটি স্বাক্ষর করা JWT, যার ভেতরের তথ্যকে বলে claim. এর header-এ লেখা থাকে কোন key দিয়ে স্বাক্ষর হয়েছে (kid), আর library সেই key এনে স্বাক্ষর মেলায়। Provider key বদলালেও আপনার অ্যাপ বদলাতে হয় না, এটিই JWKS-এর সুবিধা। আসল run:
jwks [{"kty":"RSA","kid":"keystore-CHANGE-ME","alg":"RS256","use":"sig","n":"xwQ72P9z9OYs..."}]
id_token header {"alg":"RS256","kid":"keystore-CHANGE-ME"}
৩ · একটি login-এর পুরো পথ
কোড লেখার আগে ছবিটি মাথায় বসিয়ে নিই। এই flow-র নাম Authorization Code flow, কারণ ব্রাউজারের পথে আসে একটি ছোট কোড, token নয়। Login শুরুর সময় অ্যাপ তিনটি এলোমেলো মান বানায়। প্রতিটির কাজ আলাদা, আর তিনটিই থাকে এই ব্রাউজারের session-এ।
| মান | কোথায় যায় | কে মেলায় | কী ঠেকায় |
|---|---|---|---|
| code_verifier | শুরুতে যায় শুধু hash (code challenge), আসলটি যায় token অনুরোধে | Provider | চুরি বা বসানো কোড ভাঙানো |
state | Redirect-এ গিয়ে ফেরে | আপনার অ্যাপ | অন্যের শুরু করা flow-র উত্তর এই ব্রাউজারে বসা |
nonce | ID token-এর ভেতরে ফেরে | আপনার অ্যাপ | পুরোনো ID token আবার চালানো |
এবার PKCE-র হিসাবটি নিজে চালান। এটি আসল SHA-256. প্রথম লাইনে আমরা RFC 7636-এর প্রকাশিত নমুনা দিয়ে নিজের কোড মিলিয়ে নিয়েছি, যা একটি ভালো অভ্যাস।
const { createHash, randomBytes } = require('node:crypto');
// S256: BASE64URL(SHA-256(verifier))
function challengeOf(verifier) {
const digest = createHash('sha256').update(verifier, 'ascii').digest();
return digest.toString('base64url');
}
// RFC 7636-এর প্রকাশিত নমুনা দিয়ে নিজের কোড যাচাই
const rfcV = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
const rfcC = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
console.log('rfc vector ok :', challengeOf(rfcV) === rfcC);
// প্রতি login-এ নতুন verifier: ৩২ byte এলোমেলো
const verifier = randomBytes(32).toString('base64url');
const challenge = challengeOf(verifier);
console.log('verifier len :', verifier.length);
console.log('challenge len :', challenge.length);
console.log('allowed chars :', /^[A-Za-z0-9._~-]{43,128}$/.test(verifier));
// শেষ অক্ষর বদলালে কী হয়
const last = verifier.at(-1) === 'A' ? 'B' : 'A';
const wrong = verifier.slice(0, -1) + last;
console.log('wrong matches :', challengeOf(wrong) === challenge);
console.log('right matches :', challengeOf(verifier) === challenge);ফল: rfc vector ok : true, দুটি দৈর্ঘ্যই 43, allowed chars : true, wrong matches : false, right matches : true. হিসাবটি এমন: ৩২ byte মানে ২৫৬ bit, আর base64url-এর প্রতিটি অক্ষর ৬ bit বহন করে। ⌈২৫৬ ÷ ৬⌉ = ৪৩ অক্ষর। SHA-256-এর ফলও ২৫৬ bit, তাই সেটিও ৪৩। RFC-র সীমা ৪৩ থেকে ১২৮ অক্ষর, তাই ৩২ byte ঠিক সবচেয়ে ছোট বৈধ দৈর্ঘ্য দেয়। Verifier-এর মান প্রতিবার আলাদা, কিন্তু এই ফলগুলো প্রতিবার একই।
এবার পুরো পথটি ধাপে ধাপে দেখুন। মানগুলো ছোট করে দেখানো, কিন্তু ধাপ, পথ আর status আমাদের আসল Edge run-এর।
- Edge
রহিমের ব্রাউজারবার্তা বয়ে নেয় - বাজারঘর
:47402Express + openid-client - Provider
:47401login, token
req.session.oidc = {
codeVerifier: "Xk2…(৪৩ অক্ষর)",
state: "aT9…",
nonce: "mQ4…"
}
GET localhost:47401/auth
?client_id=bazarghar-local
&response_type=code
&redirect_uri=…/auth/callback
&scope=openid email profile
&code_challenge=…&code_challenge_method=S256
&state=aT9… &nonce=mQ4…
Login: idp-1001 Password: •••••••• (নকল, lab-এর) বাজারঘর এই পাতা দেখে না, password-ও পায় না
bazarghar-local চায়: ✓ openid ✓ email ✓ profile [ Continue ]
GET localhost:47402/auth/callback
?code=…&state=aT9…&iss=http://localhost:47401
state মিলেছে ✓ তারপর:
POST /token
Authorization: Basic (client_id:client_secret)
grant_type=authorization_code
code=… redirect_uri=…
code_verifier=Xk2…
BASE64URL(SHA256(verifier)) == জমা challenge ✓
কোড মুছে ফেলা হলো, দ্বিতীয়বার চলবে না
200 → access_token, expires_in, id_token,
scope, token_type
{"sub":"idp-1001","nonce":"…",
"aud":"bazarghar-local",
"exp":1793561689,"iat":1793558089,
"iss":"http://localhost:47401"}
স্বাক্ষর (JWKS) ✓ iss ✓ aud ✓ exp ✓ nonce ✓
email এখানে নেই, UserInfo থেকে আনতে হলো
findOrLinkUser("local-idp", claims) → user 4
req.session.regenerate(…) নতুন session id
req.session.userId = 4
Set-Cookie: bg.sid=…; HttpOnly; SameSite=Lax
{"userId":4,"how":"new-user"}
| code_verifier | |
| code | |
| password | |
| আপনার session |
- শুরু। রহিম “Login” চাপলেন। অ্যাপ তিনটি মান বানিয়ে session-এ রাখল। Browser এগুলো দেখে না, দেখে শুধু cookie.
- Provider-এ পাঠানো। 302 redirect. URL-এ
code_challengeগেল, verifier নয়।stateআরnonceসাথে গেল। - Provider-এর পাতা। Password এখানেই। ঠিকানা বারে provider-এর domain দেখা যায়, যা মানুষ নিজে যাচাই করতে পারেন।
- Consent. কোন scope দেওয়া হচ্ছে, তা provider দেখায়। Google-এর মতো provider-এ একবার দিলে পরে সাধারণত আর আসে না।
- ফেরা। Provider ব্রাউজারকে আপনার callback-এ পাঠাল। URL-এ
code,stateআরiss, কোনো token নেই। - Back channel. অ্যাপ আগে
stateমেলাল। তারপর ব্রাউজার ছাড়াই সরাসরি POST করল: কোড, verifier আর client secret. - Provider-এর যাচাই। Verifier-এর hash জমা challenge-এর সাথে মিলেছে। কোডটি শেষ। উত্তরে refresh token নেই, কারণ আমরা
offline_accessচাইনি। - ID token যাচাই। Library স্বাক্ষর,
iss,aud,expআরnonceমেলাল।exp − iat= 3600, অর্থাৎ এক ঘণ্টা। আসল run-এ email ছিল না, তাই UserInfo থেকে আনতেfetchUserInfoলেগেছে। - নিজের session. User খোঁজা হলো, পুরোনো session id বাতিল করে নতুন বানানো হলো। এখান থেকে সব পর্ব ৪৫-এর মতো।
userId4, কারণ আগে থেকে ৩ জন seed করা ছিল।
এই পথের মূল শিক্ষা যদি একটি মডেলে হাতে ধরে দেখতে চান, নিচের block চালান। এটি node:http-এ বানানো ছোট একটি শেখার মডেল, আসল provider নয়। এখানে login পাতা নেই, স্বাক্ষর নেই। শুধু দুটি নিয়ম আছে: redirect URI হুবহু মিলতে হবে, আর কোড ভাঙাতে সঠিক verifier লাগবে।
// শেখার মডেল: login পাতা, signature, JWKS কিছুই নেই
const http = require('node:http');
const { createHash, randomBytes } = require('node:crypto');
const sha = (s) => createHash('sha256').update(s).digest('base64url');
const REDIRECT = 'http://app.test/callback';
const codes = new Map();
let n = 0;
const idp = http.createServer(async (req, res) => {
const u = new URL(req.url, 'http://idp.test'), q = u.searchParams;
if (u.pathname === '/authorize') {
if (q.get('redirect_uri') !== REDIRECT) { res.writeHead(400); return res.end(); }
codes.set('code-' + ++n, q.get('code_challenge'));
res.writeHead(302, { location: `${REDIRECT}?code=code-${n}` });
return res.end();
}
let body = ''; for await (const c of req) body += c;
const f = new URLSearchParams(body), saved = codes.get(f.get('code'));
codes.delete(f.get('code')); // কোড এক বারই চলে
const ok = saved && sha(f.get('code_verifier')) === saved;
res.writeHead(ok ? 200 : 400);
res.end(ok ? '{"id_token":"example"}' : '{"error":"invalid_grant"}');
});
idp.listen(0, '127.0.0.1', async () => {
const base = `http://127.0.0.1:${idp.address().port}`;
const v = randomBytes(32).toString('base64url');
const start = (uri) => fetch(`${base}/authorize?` + new URLSearchParams({ redirect_uri: uri, code_challenge: sha(v) }), { redirect: 'manual' });
const codeOf = (r) => new URL(r.headers.get('location')).searchParams.get('code');
const token = (code, code_verifier) => fetch(`${base}/token`, { method: 'POST', body: new URLSearchParams({ code, code_verifier }) }).then(async (r) => r.status + ' ' + await r.text());
console.log('bad redirect :', (await start(REDIRECT + '/')).status);
const c1 = codeOf(await start(REDIRECT));
console.log('right verifier:', await token(c1, v));
console.log('replay :', await token(c1, v));
console.log('wrong verifier:', await token(codeOf(await start(REDIRECT)), 'x'.repeat(43)));
idp.closeAllConnections(); idp.close();
});ফল: bad redirect : 400, right verifier: 200 {"id_token":"example"}, replay : 400 {"error":"invalid_grant"}, wrong verifier: 400 {"error":"invalid_grant"}. শেষে একটি / বেশি হওয়াতেই redirect বাতিল। একই কোড দ্বিতীয়বার চলেনি। আর যে কোডটি ঠিক ছিল, সেটিও ভুল verifier দিয়ে ভাঙেনি। সামনে আসল provider-ও ঠিক এই তিন আচরণ করবে।
৪ · Express 5-এ openid-client
Protocol হাতে লিখবেন না। openid-client Node-জগতে বহু বছরের পরিচিত OIDC client library, আর এর বর্তমান major version হলো ৬ (আমাদের run-এ 6.8.8). Version 6-এর API আগের চেয়ে পুরো আলাদা: এখন আর Issuer বা generators class নেই, সব সাধারণ function. AI আর পুরোনো টিউটোরিয়াল প্রায়ই আগের ধরন লেখে, সেটি মিলিয়ে নেবেন। Library-টি ESM, আর Node 20 থেকে চলে।
প্রথমে শুরুতে একবার discovery. allowInsecureRequests শুধু এই local http lab-এর জন্য, production-এ এই লাইন থাকবে না।
import express from "express";
import session from "express-session";
import * as client from "openid-client";
import { findOrLinkUser, LinkRefused } from "./link.ts";
const BASE = "http://localhost:47402";
const REDIRECT_URI = `${BASE}/auth/callback`;
const config = await client.discovery(
new URL("http://localhost:47401"),
"bazarghar-local",
"example-client-secret-not-real",
undefined,
{ execute: [client.allowInsecureRequests] }, // শুধু এই local http lab
);
const app = express();
app.use(session({
name: "bg.sid",
secret: "example-session-secret",
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, sameSite: "lax", secure: false, maxAge: 3_600_000 },
}));Secret এখানে নকল, আর সুবিধার জন্য কোডেই লেখা। বাস্তবে এগুলো environment variable থেকে আসবে (পর্ব ৭৮, Environment আর secret). sameSite: "lax" ইচ্ছা করেই দেওয়া, "strict" নয়। Provider থেকে ফেরা একটি cross-site redirect, আর Strict হলে ব্রাউজার সেই অনুরোধে আপনার cookie পাঠাবে না, ফলে callback নিজের session-ই পাবে না। আমাদের lab-এ দুই পক্ষই localhost, অর্থাৎ same-site, তাই এই বিষয়টি এখানে চালিয়ে দেখানো যায়নি। এটি cookie-র নিয়ম থেকে বলা (দেখুন CSRF আর cookie পর্ব).
এবার login শুরুর route:
app.get("/auth/login", async (req, res) => {
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
const state = client.randomState();
const nonce = client.randomNonce();
req.session.oidc = { codeVerifier, state, nonce };
const url = client.buildAuthorizationUrl(config, {
redirect_uri: REDIRECT_URI,
scope: "openid email profile",
code_challenge: codeChallenge,
code_challenge_method: "S256",
state,
nonce,
});
req.session.save(() => res.redirect(url.href));
});শেষ লাইনে session.save খেয়াল করুন। Redirect পাঠানোর আগে session store-এ পৌঁছানো নিশ্চিত করা হলো। না হলে ব্যবহারকারী callback-এ ফিরে এসে দেখতে পারেন, মানগুলো তখনো জমা হয়নি। এবার callback, যেখানে আসল কাজ:
app.get("/auth/callback", async (req, res) => {
const oidc = req.session.oidc;
if (!oidc) return res.status(400).json({ error: "no_login_in_progress" });
delete req.session.oidc; // এক login, এক ব্যবহার
try {
const tokens = await client.authorizationCodeGrant(
config,
new URL(req.originalUrl, BASE),
{
pkceCodeVerifier: oidc.codeVerifier,
expectedState: oidc.state,
expectedNonce: oidc.nonce,
idTokenExpected: true,
},
);
const claims = tokens.claims()!;
const info = await client.fetchUserInfo(config, tokens.access_token, claims.sub);
const { user, how } = await findOrLinkUser("local-idp", {
sub: claims.sub,
email: info.email,
email_verified: info.email_verified,
name: info.name,
});
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: "session_error" });
req.session.userId = user.id;
req.session.save(() => res.redirect(`/me?how=${how}`));
});
} catch (e: any) {
if (e instanceof LinkRefused) return res.status(409).json({ error: e.message });
console.log("[app] callback rejected:", e.code, e.cause?.message);
res.status(400).json({ error: "login_failed", code: e.code });
}
});তিনটি লাইন আলাদা করে দেখুন। fetchUserInfo-এর তৃতীয় argument হলো claims.sub: UserInfo-র sub ID token-এর সাথে না মিললে library থামবে। regenerate login-এর আগের session id বাতিল করে, যা session fixation ঠেকায়। আর error-এর বিস্তারিত log-এ যায়, ব্যবহারকারীর কাছে যায় শুধু একটি ছোট কোড (পর্ব ২৮-এর নিরাপদ বার্তা).
আসল run. (src/app.ts-এর উপরের তিনটি অংশে log-এর দুটি লাইন বাদ দেওয়া, যা নিচের শেষ লাইনটি লেখে.) Playwright Edge খুলল, নকল ব্যবহারকারী idp-1001 দিয়ে login করল। প্রতিটি navigation-এর status আর URL (সত্যিকারের run; মান লুকিয়ে শুধু parameter-এর নাম, আর interaction id ছোট করা):
$ node pw.mjs
browser 156.0.4314.58
== first login (new user): user=idp-1001
302 GET http://localhost:47402/auth/login
303 GET http://localhost:47401/auth?redirect_uri&scope&code_challenge&code_challenge_method&state&nonce&client_id&response_type
200 GET http://localhost:47401/interaction/hcWX… (login পাতা)
303 POST http://localhost:47401/interaction/hcWX…
303 GET http://localhost:47401/auth/hcWX…
200 GET http://localhost:47401/interaction/CHw0… (consent পাতা)
303 POST http://localhost:47401/interaction/CHw0…
303 GET http://localhost:47401/auth/CHw0…
302 GET http://localhost:47402/auth/callback?code&state&iss
200 GET http://localhost:47402/me?how
body {"userId":4,"how":"new-user"}
[app] token_response keys access_token,expires_in,id_token,scope,token_type
Callback-এর লাইনে দেখুন, code আর state-এর পাশে iss ফিরে এসেছে। এই parameter (RFC 9207) জানায় উত্তরটি কোন provider-এর। একাধিক provider থাকলে এটি এক provider-এর উত্তর অন্যটির বলে চালানো ঠেকায়।
৫ · state আর অন্য পাহারা ভাঙলে কী হয়
“কাজ করছে” দেখলেই যাচাই শেষ নয়। নিরাপত্তার কোডে দেখতে হয়, ভুল অনুরোধ থামছে কি না। আমরা নিজের lab-এ প্রতিটি পাহারা ইচ্ছা করে ভেঙে দেখেছি। নিচের table আসল run-এর, আসল বার্তায়।
| কী বদলালাম | কে থামাল | আসল বার্তা |
|---|---|---|
Callback-এ অন্য state | openid-client | unexpected "state" response parameter value |
expectedNonce ভিন্ন | openid-client | unexpected ID Token "nonce" claim value |
nonce পাঠিয়ে expectedNonce না দেওয়া | openid-client | একই বার্তা |
| ভুল verifier | Provider | code_verifier does not match code_challenge |
| একই কোড দ্বিতীয়বার | Provider | authorization code already consumed |
Token অনুরোধে ভিন্ন redirect_uri | Provider | authorization code redirect_uri mismatch |
একটি সূক্ষ্ম কথা। Provider-এর ওই ব্যাখ্যা আসে তার নিজের log-এ। Client-এর হাতে আসে শুধু {"error":"invalid_grant","error_description":"grant request is invalid"}. অর্থাৎ বাইরের কাউকে সে কারণ জানায় না। Debug করার সময় তাই দুই পাশের log-ই দেখতে হয়।
আরেকটি আসল পর্যবেক্ষণ: আমাদের lab-এর provider secret-সহ client-এর ক্ষেত্রে PKCE ছাড়া অনুরোধও login পাতায় নিয়ে গেছে। তাই provider থামাবে, এই ভরসায় থাকবেন না। PKCE পাঠানোর দায়িত্ব আপনার অ্যাপের।
CSRF: state কী ঠেকায়
কল্পনা করুন, কেউ নিজের account-এ login শুরু করে শেষের callback URL-টি নিজের কাছে রেখে দিল। সে এখন সেই URL নাদিয়ার ব্রাউজারে খোলাতে পারলে কী হবে? state না থাকলে নাদিয়া অজান্তে অন্যের account-এ login হয়ে যাবেন, আর সেখানে নিজের ঠিকানা বা কার্ডের তথ্য রাখবেন। Login CSRF-এর এই ধরনের কথা আগে CSRF পর্বে পড়েছেন। আমাদের run-এ ঠিক এই পরিস্থিতিই দুটি আলাদা ব্রাউজার context-এ চালানো হয়েছে।
/auth/callback?code=C-A&state=st-A&iss=… st-A জমা আছে শুধু ব্রাউজার A-র session-এ
GET /auth/callback?code=C-A&state=st-A 400 {"error":"no_login_in_progress"} সত্যিকারের run-এর উত্তর
session.oidc.state = st-B
provider-এর পাতা খোলা, এখনো শেষ করেননি
GET /auth/callback?code=C-A&state=st-A
জমা : st-B
এসেছে : st-A → মেলেনি
400 {"error":"login_failed",
"code":"OAUTH_INVALID_RESPONSE"}
log: unexpected "state" response parameter value
Token অনুরোধ যায়ইনি, কোড অব্যবহৃত
code=C-A ভাঙানো হতো session.userId = অন্যের account নাদিয়া ভাবতেন এটি নিজের account
| oidc.state | |
| userId |
- URL তৈরি। অন্য একজনের ব্রাউজারে flow শেষ। তার
stateশুধু তার নিজের session-এই আছে। - Session-এ কিছু নেই। নাদিয়া login শুরুই করেননি। তাই শুরুর লাইনেই থামল।
- এবার কঠিন ক্ষেত্র। নাদিয়া নিজের login শুরু করেছেন, তাঁর session-এ
st-Bআছে। - তুলনা। URL-এর মান তাঁর session-এর মান নয়।
stateতাই ব্রাউজারের সাথে বাঁধা। - থেমে গেল। Library token অনুরোধ পাঠানোর আগেই থামল। আসল run-এ status ছিল 400.
- পাহারা না থাকলে। এটি নমুনা, এমন কোড আমরা চালাইনি। ক্ষতিটি চুপচাপ হতো, কারণ login সফলই দেখাত।
openid-client এই তুলনা নিজে করে। তবু নিয়মটি হাতের কোডে একবার দেখলে পরিষ্কার হয়। নিচের block একটি শেখার মডেল, যেখানে Map হলো নকল session store. খেয়াল করুন, মান মেলার আগেই সেটি মুছে ফেলা হয়, যাতে দ্বিতীয় চেষ্টা না থাকে।
const { randomBytes, timingSafeEqual } = require('node:crypto');
// প্রতি ব্রাউজারের session (Map = শেখার জন্য নকল store)
const sessions = new Map();
function startLogin(sid) {
const state = randomBytes(16).toString('base64url');
sessions.set(sid, { state });
return state;
}
function sameText(a, b) {
const x = Buffer.from(a), y = Buffer.from(b);
return x.length === y.length && timingSafeEqual(x, y);
}
function callback(sid, query) {
const pending = sessions.get(sid);
if (!pending) return 'rejected: no_login_in_progress';
sessions.delete(sid); // মান একবারই চলে, মিলুক বা না মিলুক
if (!sameText(pending.state, query.state)) return 'rejected: state_mismatch';
return 'accepted: exchange code ' + query.code;
}
const mine = startLogin('sid-nadia');
console.log('own callback :', callback('sid-nadia', { state: mine, code: 'C1' }));
console.log('same URL again :', callback('sid-nadia', { state: mine, code: 'C1' }));
// অন্য ব্রাউজারের flow-র কোড নাদিয়ার ব্রাউজারে পৌঁছাল
const other = startLogin('sid-other');
console.log('no login begun :', callback('sid-nadia', { state: other, code: 'C2' }));
startLogin('sid-nadia');
console.log('own login open :', callback('sid-nadia', { state: other, code: 'C2' }));ফল চারটি লাইন: accepted: exchange code C1, তারপর rejected: no_login_in_progress, আবার rejected: no_login_in_progress, আর শেষে rejected: state_mismatch. দ্বিতীয় লাইনটি দেখায়, একই URL আবার খুললে (ব্যবহারকারী Back চাপলে এটিই হয়) কোনো কাজ হয় না। আসল Edge run-এর দুই উত্তরও এই দুই ধরনেরই ছিল। ১৬ byte state-এ ⌈১২৮ ÷ ৬⌉ = ২২ অক্ষর হয়।
৬ · Redirect URI, dev আর production, Google আর মোবাইল
Redirect URI হলো সেই ঠিকানা, যেখানে provider কোড পৌঁছে দেয়। তাই provider আগে থেকে নিবন্ধিত তালিকার সাথে হুবহু মেলায়। কেন এত কড়া, তা OAuth পর্বে আছে: কোড ভুল ঠিকানায় গেলেই খেলা শেষ। আমাদের provider-এ নিবন্ধিত ছিল শুধু http://localhost:47402/auth/callback. তিনটি প্রায়-একই ঠিকানা পাঠালাম (সত্যিকারের run, ছোট করে সাজানো):
== redirect_uri http://localhost:47402/auth/callback/ 400
== redirect_uri http://127.0.0.1:47402/auth/callback 400
== redirect_uri http://localhost:9999/auth/callback 400
error: invalid_redirect_uri
error_description: redirect_uri did not match any of the client's registered redirect_uris
দ্বিতীয়টি বিশেষ করে মনে রাখুন। localhost আর 127.0.0.1 একই মেশিন, কিন্তু string আলাদা, তাই provider-এর কাছে আলাদা ঠিকানা। আর ব্রাউজারের কাছেও আলাদা সাইট, তাই cookie-ও মিলবে না।
Library বদলালে path-ও বদলায়, এটি আমরা নিজেরাই ঠেকে শিখেছি। Better Auth-এর lab-এ প্রথমে একটি পুরোনো ধরনের path (/api/auth/oauth2/) নিবন্ধন করেছিলাম। 1.7.15 আসলে পাঠাল /api/auth/, আর provider সোজা 400 দিল। সমাধান হলো অনুমান না করে, যে URL আসলে গেল সেটি দেখা।
Dev, staging আর production
| পরিবেশ | Callback (কাল্পনিক) | খেয়াল রাখুন |
|---|---|---|
| Dev | http://localhost:3000/auth/callback | Provider অনুমতি দিলে http শুধু localhost-এ |
| Staging | https://staging.bazarghar.example/auth/callback | আলাদা client, আলাদা secret |
| Production | https://bazarghar.example/auth/callback | শুধু https, শেষের / মিলিয়ে |
তিনটি অভ্যাস কাজে দেয়। প্রতিটি পরিবেশের জন্য আলাদা client id ও secret রাখুন, যাতে dev-এর secret ফাঁস হলে production নিরাপদ থাকে। ঠিকানা APP_BASE_URL ধরনের environment variable থেকে নিন, অনুরোধের Host header থেকে বানাবেন না, কারণ সেটি বাইরে থেকে পাঠানো মান। আর reverse proxy-র পেছনে চললে Express-এ trust proxy ঠিক করুন এবং cookie-তে secure: true দিন।
Google-এর setup কথায়
পরিষ্কার করে বলি: এই পর্বে আসল Google account দিয়ে কিছু চালানো হয়নি, আর এই কোর্স দাবি করে না যে এখানে তা করা যাবে। Google Cloud-এর console-এর নাম ও মেনু প্রায়ই বদলায়, তাই নিচের ধাপগুলো সাধারণ ধারণা। আসল ধাপ দেখবেন Google-এর “OpenID Connect” docs-এ।
- Google Cloud console-এ একটি project বানান।
- Consent screen-এর branding পূরণ করুন: অ্যাপের নাম, support email ইত্যাদি। লেখার সময়ে docs-এ এর নাম Branding পাতা।
- Clients পাতায় “Web application” ধরনের একটি OAuth client বানান। Client id আর secret পাবেন।
- Authorized redirect URI-তে নিজের callback হুবহু বসান। Google-এর docs বলে scheme, অক্ষরের ছোট-বড় আর শেষের
/, সব মিলতে হবে। না মিললে error-এর নামredirect_uri_mismatch. - কোডে issuer বসান
https://accounts.google.com, আরallowInsecureRequestsবাদ দিন। Discovery ঠিকানাhttps://accounts.google.com/, বাকিটা একই কোডে চলার কথা।.well-known/ openid-configuration
Google-এর docs আরও দুটি কথা বলে, যা পরের অংশে কাজে লাগবে। ব্যবহারকারীর স্থায়ী চাবি হিসেবে sub ব্যবহার করতে হবে, email নয়, কারণ email বদলাতে পারে। আর docs-এর নমুনা token-এ email_verified string "true" হিসেবে দেখা যায়, যদিও table-এ boolean লেখা। তাই আসল provider-এর payload একবার দেখে নিন। আমাদের === true শর্তে string এলে login আটকাবে, কিন্তু ভুল করে কাউকে ঢোকাবে না।
মোবাইল: secret নেই, তাই PKCE অপরিহার্য
মোবাইল app হলো public client: secret রাখলেও যে কেউ app খুলে বের করতে পারে। তাই সেখানে PKCE-ই মূল ভরসা। Web server-এর মতো একটি secret-সহ confidential client সেখানে হয় না। RFC 8252 (২০১৭) বলে, login চলবে সিস্টেমের ব্রাউজারে, app-এর ভেতরের embedded web view-তে নয়, যাতে app password দেখতে না পায়। ফেরার পথ তিন রকম: custom URL scheme (যেমন bazarghar://callback), claimed HTTPS link, আর desktop app-এর জন্য loopback ঠিকানা। Custom scheme অন্য app-ও দাবি করতে পারে, তাই সম্ভব হলে claimed link ভালো, আর PKCE সব ক্ষেত্রেই থাকবে। মোবাইলে ফেরার পরে সাধারণত app নিজের backend-এ কোড বা ID token পাঠিয়ে backend-এর session বা token নেয়।
৭ · Prisma Account table আর নিরাপদ linking
একজন মানুষের password আর Google, দুটোই থাকতে পারে। তাই login-এর পথকে User-এর ঘরে না রেখে আলাদা table-এ রাখুন। একটি user-এর অনেক account (পর্ব ৪১-এর one-to-many).
model User {
id Int @id @default(autoincrement())
email String @unique
emailVerified Boolean @default(false)
passwordHash String? // social-only হলে null
name String?
accounts Account[]
createdAt DateTime @default(now())
@@map("users")
}
model Account {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
provider String
providerAccountId String
createdAt DateTime @default(now())
@@unique([provider, providerAccountId])
@@index([userId])
@@map("accounts")
}providerAccountId হলো provider-এর sub. OIDC Core-এর কথা স্পষ্ট: iss আর sub একসাথেই ব্যবহারকারীর একমাত্র স্থায়ী পরিচয়। এখানে provider ঘরটি iss-এর কাজ করছে, কারণ এক provider-এর sub আরেকজনের সাথে মিলে যেতে পারে। সেজন্য unique হলো দুটি ঘর মিলে, sub একা নয়। Email এখানে পরিচয়ের চাবি নয়, শুধু “কোন user-এর সাথে জোড়া যেতে পারে” তার একটি সূত্র।
Linking-এর নিয়ম
কঠিন প্রশ্নটি হলো: সালমা আগে email আর password দিয়ে account খুলেছিলেন। আজ একই email-এর Google দিয়ে এলেন। জোড়া দেবেন, নাকি নতুন user? জোড়া দিলে সুবিধা, কিন্তু ভুল জোড়ায় একজনের account আরেকজনের হাতে যায়। নিয়মটি ছবিতে:
Login এলে কী করবেন
দুটি “না” মানে নিজে থেকে জোড়া নয়
চতুর্থ প্রশ্নটি কেন? কারণ এখানেই pre-hijacking-এর ফাঁদ। ধরুন নাদিয়া বাজারঘরে কখনো আসেননি। অন্য কেউ নাদিয়ার email দিয়ে একটি password account খুলে রাখল, যা যাচাই হয়নি। পরে নাদিয়া Google দিয়ে এলেন। Provider-এর দিকে email_verified সত্য, তাই অসতর্ক অ্যাপ জোড়া দিল। এখন নাদিয়া ভাবছেন account তাঁর, অথচ আগের password-ও চলছে। মালিকের আগেই account-টি দখল হয়ে ছিল, তাই নাম “pre”-hijacking. Sudhodanan আর Paverd-এর ২০২২ সালের গবেষণা (USENIX Security) ৭৫টি জনপ্রিয় সেবা পরীক্ষা করে অন্তত ৩৫টিতে এই ধরনের কোনো না কোনো ফাঁক পেয়েছিল। তাদের একটি পরামর্শও ঠিক এই নিয়মটিই: জোড়া দেওয়ার আগে নিশ্চিত হতে হবে, মানুষটি এই মুহূর্তে দুই দিকই নিয়ন্ত্রণ করছেন।
export async function findOrLinkUser(provider: string, c: Claims) {
// ১. আগে (provider, sub) দিয়ে খোঁজা, email দিয়ে নয়
const known = await prisma.account.findUnique({
where: { provider_providerAccountId: { provider, providerAccountId: c.sub } },
include: { user: true },
});
if (known) return { user: known.user, how: "existing-account" };
const local = c.email
? await prisma.user.findUnique({ where: { email: c.email } })
: null;
// ২. একই email-এর কেউ নেই: নতুন user
if (!local) {
const user = await prisma.user.create({
data: {
email: c.email ?? `${provider}-${c.sub}@no-email.invalid`,
emailVerified: c.email_verified === true,
name: c.name,
accounts: { create: { provider, providerAccountId: c.sub } },
},
});
return { user, how: "new-user" };
}
// ৩. একই email: দুই দিক verified না হলে নিজে থেকে জোড়া নয়
if (c.email_verified !== true) throw new LinkRefused("provider_email_not_verified");
if (!local.emailVerified) throw new LinkRefused("local_email_not_verified");
await prisma.account.create({
data: { userId: local.id, provider, providerAccountId: c.sub },
});
return { user: local, how: "linked-verified-email" };
}Edge-এ চার রকম মানুষ চালালাম। আগে থেকে table-এ ছিলেন: সালমা (id 1, যাচাই করা), নাদিয়া (id 2, যাচাই হয়নি, password আছে), করিম (id 3, যাচাই করা, কিন্তু provider-এ তাঁর email যাচাই হয়নি)। আসল ফল, পাশাপাশি সাজিয়ে:
== second login: user=idp-1001 {"userId":4,"how":"existing-account"}
== verified both sides: user=idp-1003 {"userId":1,"how":"linked-verified-email"}
== local email unverified: user=idp-1004 {"error":"local_email_not_verified"}
== provider email unverified: user=idp-1002 {"error":"provider_email_not_verified"}
accounts table:
id │ userId │ provider │ providerAccountId
1 │ 4 │ local-idp │ idp-1001
2 │ 1 │ local-idp │ idp-1003
দুটি প্রত্যাখ্যান হওয়া login-এর জন্য accounts-এ কোনো সারি নেই, ঠিক যেমন চাওয়া হয়েছিল। এবার সালমার সফল জোড়াটি কোডের সাথে মিলিয়ে দেখুন।
1const known = findAccount(provider, c.sub);2if (known) return known.user;3const local = findUser(c.email);4if (!local) return createUser(c);5if (c.email_verified !== true) refuse();6if (!local.emailVerified) refuse();7createAccount(local.id, provider, c.sub);8return local;
| c.sub | |
| known | |
| local | |
| c.email_verified | |
| local.emailVerified | |
| accounts-এ সারি |
- স্থায়ী চাবিতে খোঁজা। সালমার
idp-1003এখনোaccounts-এ নেই, আছে শুধু রহিমের ১টি সারি। তাইknown=null. - Email-এ খোঁজা।
salma@bazarghar.testপাওয়া গেল: user 1. তাই লাইন ৪ চলবে না, নতুন user তৈরি হবে না। - Provider-এর যাচাই।
email_verifiedসত্যিইtrue. শর্ত পার। - নিজের দিকের যাচাই। বাজারঘরে সালমার email আগেই যাচাই করা। Pre-hijacking-এর ঝুঁকি নেই। নাদিয়া ঠিক এই লাইনে আটকেছিলেন।
- জোড়া। নতুন সারি
(userId 1, local-idp, idp-1003). আসল run-এর উত্তর উপরে দেখা যাচ্ছে। ব্যবহারকারীকে এখন একটি বার্তা বা email-এ জানানো ভালো। - পরের বার। এবার প্রথম লাইনেই পাওয়া যাবে, email আর দেখাই হবে না। রহিমের দ্বিতীয় login-এ আসল run-এ এই পথ
existing-accountদেখিয়েছে। এই ধাপের মান সালমার জন্য অনুমান, চালানো হয়নি।
যাঁর login আটকাল, তাঁর জন্য পথ কী? Error দেখিয়ে ফেলে রাখবেন না। বলুন: “এই email-এ আগে থেকে account আছে। আগে সেই পথে login করে Settings থেকে Google জুড়ুন।” Login করা অবস্থায় জোড়া দিলে মানুষটির নিয়ন্ত্রণ প্রমাণিত হয়। আর নিজের দিকের email যদি যাচাই না হয়ে থাকে, তাহলে যাচাই করা, আগের password বাতিল করা, বা ফেলে রাখা অযাচাই account মুছে ফেলার নিয়ম ঠিক করুন (পর্ব ৪৯, Account-এর কাজ).
নিচের শেখার মডেলে একই নিয়ম in-memory table-এ চলছে, এবং একটি বাড়তি ঘটনা আছে: একজনের provider-এর email বদলেছে, কিন্তু sub একই।
// শেখার মডেল: দুই table, Map দিয়ে
const users = [
{ id: 1, email: 'salma@bazarghar.test', verified: true },
{ id: 2, email: 'nadia@bazarghar.test', verified: false },
{ id: 3, email: 'karim@bazarghar.test', verified: true },
];
const accounts = new Map(); // 'provider:sub' -> userId
function login(c) {
const key = 'google:' + c.sub;
if (accounts.has(key)) return `user ${accounts.get(key)} (known sub)`;
const local = users.find((u) => u.email === c.email);
if (!local) {
const id = users.push({ id: users.length + 1, email: c.email,
verified: c.email_verified === true });
accounts.set(key, id);
return `user ${id} (new)`;
}
if (c.email_verified !== true) return 'refused: provider email not verified';
if (!local.verified) return 'refused: local email not verified';
accounts.set(key, local.id);
return `user ${local.id} (linked)`;
}
const t = (sub, email, ok) => console.log(sub.padEnd(6), email.padEnd(25),
login({ sub, email, email_verified: ok }));
t('g-1', 'salma@bazarghar.test', true);
t('g-2', 'nadia@bazarghar.test', true);
t('g-3', 'karim@bazarghar.test', false);
t('g-4', 'rahim@bazarghar.test', true);
t('g-4', 'new-rahim@bazarghar.test', true); // একই sub, email বদলেছে
console.log('accounts:', [...accounts.keys()].join(' '));ফল: g-1 → user 1 (linked), g-2 → refused: local email not verified, g-3 → refused: provider email not verified, g-4 → user 4 (new). শেষ লাইনে একই g-4 নতুন email নিয়ে এলো, আর ফল user 4 (known sub). Email দিয়ে পরিচয় ধরলে এখানে একটি ভুল নতুন user হতো। শেষে accounts: google:g-1 google:g-4, অর্থাৎ দুটি সারি।
৮ · Auth library: কে কোথায় আছে
এতক্ষণ যা বানালাম, তা শেখার জন্য দারুণ। কিন্তু একটি পূর্ণ auth-এ আরও আছে: email যাচাই, password reset, একাধিক provider, MFA, session তালিকা, rate limit. তাই library-র কথা আসে। এই জগৎ গত দুই বছরে অনেক বদলেছে, আর AI-এর জ্ঞান প্রায়ই পুরোনো। লেখার সময়ের অবস্থা (npm আর প্রকল্পের নিজের ঘোষণা দেখে):
| নাম | অবস্থা (নভেম্বর ২০২৬) | ভালো যেখানে | সতর্কতা |
|---|---|---|---|
| openid-client | 6.8.8, সক্রিয় | নিজে flow নিয়ন্ত্রণ, অল্প provider | Session, linking, UI সব আপনার |
| Better Auth | 1.7.15, সক্রিয়, MIT | নিজের database-এ পূর্ণ auth, TypeScript, plugin | দ্রুত বদলায়, পুরোনো উদাহরণ ভাঙে |
Auth.js (next-auth) | latest 4.24.15; v5 এখনো beta (5.0.0-beta.32); @auth/express 0.12.3 | যেসব project-এ আগে থেকে আছে | সেপ্টেম্বর ২০২৫ থেকে রক্ষণ Better Auth দলের হাতে, ঘোষণায় নিরাপত্তা ও জরুরি কাজের কথা, আর নতুন দলের জন্য Better Auth সুপারিশ |
| Lucia | 3.2.2, npm-এ deprecated | Session-এর নিজের কোড শেখার উৎস | মার্চ ২০২৫-এ বন্ধ। সাইটটি এখন একটি ফাইলের নমুনা কোড দেয়, প্যাকেজ নয় |
| Passport | 0.7.0 | পুরোনো Express অ্যাপে অনেক দেখবেন | কম কাজ করে দেয়, নিরাপদ linking আপনার ঘাড়ে |
| Hosted auth | Clerk, Auth0, WorkOS, Firebase Authentication, Supabase Auth, Amazon Cognito ইত্যাদি | দ্রুত শুরু, MFA ও enterprise SSO তৈরি | দাম ব্যবহারের সাথে বাড়ে, vendor lock-in, ডেটা তাদের কাছে |
| নিজে চালানো IdP | Keycloak ইত্যাদি | অনেক অ্যাপের একটি কেন্দ্রীয় login | একটি আলাদা সেবা চালানোর দায়িত্ব |
এখানে কোনো একটি নামের প্রচার করছি না। Hosted সেবার নামগুলো শুধু উদাহরণ, দাম আর শর্ত নিজে দেখে নিন। Hosted auth-এ আপনি একটি lock-in কেনেন, আর open-source library-তে একটি রক্ষণের ঝুঁকি, যা Lucia আর Auth.js-এর গল্পে দেখা গেল।
Better Auth-এর আসল setup
আমরা Better Auth বেছে নিয়েছি, কারণ এটি সক্রিয়, Express-এ চলে, আর Prisma-য় নিজের database-এ থাকে। একই local provider-এর সাথে জুড়লাম। আসল Google-এর জন্য এর নিজের socialProviders option আছে, কিন্তু আমরা OIDC-র generic plugin ব্যবহার করেছি, কারণ provider আমাদের নিজের।
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { genericOAuth } from "better-auth/plugins";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma/client.ts";
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
export const prisma = new PrismaClient({ adapter });
export const auth = betterAuth({
database: prismaAdapter(prisma, { provider: "postgresql" }),
emailAndPassword: { enabled: true },
plugins: [
genericOAuth({
config: [{
providerId: "local-idp",
clientId: "bazarghar-local",
clientSecret: "example-client-secret-not-real",
discoveryUrl: "http://localhost:47401/.well-known/openid-configuration",
scopes: ["openid", "email", "profile"],
pkce: true,
}],
}),
],
});import express from "express";
import { toNodeHandler, fromNodeHeaders } from "better-auth/node";
import { auth } from "./auth.ts";
const app = express();
app.all("/api/auth/*splat", toNodeHandler(auth)); // express.json-এর আগে
app.use(express.json());
app.get("/me", async (req, res) => {
const s = await auth.api.getSession({ headers: fromNodeHeaders(req.headers) });
if (!s) return res.status(401).json({ error: "not_signed_in" });
res.json({ user: { id: s.user.id, email: s.user.email, emailVerified: s.user.emailVerified } });
});Express 5-এ wildcard-এর নাম লাগে, তাই *splat; এটি Express 4-এর * থেকে আলাদা (পর্ব ২৫-এ Express route). আর auth handler বসে express.json()-এর আগে। Schema বানাল এর CLI, যেটি এখন npm-এ auth নামে। পুরোনো @better-auth/cli নামটি পিছিয়ে আছে (1.4.21). সত্যিকারের run:
$ npx auth@1.7.15 generate --config src/auth.ts --yes
…
🚀 Schema was overwritten successfully!
$ grep -n "model " prisma/schema.prisma
11:model User {
26:model Session {
42:model Account {
62:model Verification {
“overwritten” শব্দটি খেয়াল করুন: এই কমান্ড schema ফাইলে হাত দেয়, তাই আগে নিরাপদ জায়গায় রেখে চালান আর diff পড়ুন। তারপর prisma db push আর generate. Edge-এ login (আসল run, ছোট করা):
303 GET http://localhost:47401/auth?response_type&client_id&state&scope&redirect_uri&nonce&code_challenge_method&code_challenge
…
302 GET http://localhost:47403/api/auth/callback/local-idp?code&state&iss
200 GET http://localhost:47403/me
body {"user":{"id":"z0onC69L…","email":"rahim@bazarghar.test","emailVerified":true}}
cookies better-auth.session_token httpOnly=true sameSite=Lax
Library আমাদের হাতের কোডের মতোই PKCE, state আর nonce পাঠিয়েছে, আর login শেষে নিজের session cookie বসিয়েছে। Linking-এর পরীক্ষাটিই আসল কৌতূহল। আগে একটি অযাচাই password account, তারপর একই email-এ provider দিয়ে login (আসল run, ছোট করা, শেষের table-এর ঘর বাছাই করা):
signup nadia@bazarghar.test 200
== idp-1004
url http://localhost:47403/api/auth/error?error
body ERROR Something went wrong CODE: account_not_linked …
signup salma@bazarghar.test 200
salma local marked verified
== idp-1003
url http://localhost:47403/me
body {"user":{…,"email":"salma@bazarghar.test","emailVerified":true}}
email │ providerId │ has_pw │ has_at │ has_idt
nadia@bazarghar.test │ credential │ true │ false │ false
salma@bazarghar.test │ credential │ true │ false │ false
salma@bazarghar.test │ local-idp │ false │ true │ true
আমরা কিছু না বলতেই Better Auth আমাদের হাতে লেখা নিয়মই মেনেছে: নাদিয়া আটকেছেন, সালমা জুড়েছেন। 1.7.15-এর source-এ দেখা গেল, requireLocalEmailVerified-এর ডিফল্ট true. তবে docs অনুযায়ী trustedProviders-এ কোনো provider যোগ করলে তার email যাচাই না হলেও জোড়া হতে পারে, তাই এই option সাবধানে দেবেন।
আরও দুটি জিনিস চোখে পড়ল। সারিতে has_at আর has_idt true, অর্থাৎ library provider-এর token database-এ রেখেছে। Source-এ দেখা গেল, এগুলো account.encryptOAuthTokens চালু করলে তবেই encrypt হয়, ডিফল্টে নয়। আপনার যদি এই token-এর পরে দরকার না থাকে, তাহলে কী রাখছেন, ভেবে দেখুন। আর generate করা Account model-এ accountId ও providerId-র ওপর unique নেই, শুধু userId-র index. ডুপ্লিকেট আটকায় library-র কোড। Database-এর স্তরেও চাইলে নিজে constraint যোগ করতে হবে, তবে পরের generate সেটি মুছে দিতে পারে কি না দেখে নেবেন।
৯ · নিজে বানাবেন, library, নাকি কিনবেন
এই সিদ্ধান্তের একটি সরল উত্তর নেই, কিন্তু একটি পরিষ্কার সীমারেখা আছে: ক্রিপ্টোগ্রাফি আর protocol নিজে লিখবেন না। “নিজে বানানো” মানেও অন্তত openid-client ব্যবহার করা। আমাদের হাতের মডেলে স্বাক্ষর যাচাই একেবারেই নেই, আর সেটিই সবচেয়ে সহজে ভুল হয়।
| আপনার অবস্থা | যুক্তিসঙ্গত পথ | কেন |
|---|---|---|
| ছোট অ্যাপ, একটি বা দুটি provider, নিজের session আগে থেকে আছে | openid-client + নিজের table | কোড ছোট, নির্ভরতা কম, সব বোঝা যায় |
| নতুন অ্যাপ, password, email যাচাই, MFA সব লাগবে | Better Auth-এর মতো library | Reset, linking, session-এর কোড লিখতে হয় না, ডেটা আপনার database-এ |
| ব্যবসায়িক ক্রেতা, SAML, audit, দ্রুত বাজারে যাওয়া | Hosted সেবা | Enterprise SSO নিজে বানানো খরচসাপেক্ষ |
| কড়া ডেটা নিয়ম, বাইরে তথ্য রাখা যাবে না | Library বা নিজের IdP | ডেটা আপনার নিয়ন্ত্রণে |
| কয়েকটি অ্যাপে একটিই login | নিজের IdP বা hosted | প্রতিটি অ্যাপে আলাদা বানানো অপচয় |
চারটি প্রশ্ন করুন। কে রাতে নিরাপত্তা patch দেবে? ডেটা বের করে অন্য কোথাও নেওয়া কতটা সহজ? Password hash রপ্তানি করা যায় তো? Library যদি বন্ধ হয়, আপনার table কি তবু পড়া যাবে? মনে রাখবেন, library নিলেও পাহারার দায় যায় না। Option-গুলো আপনাকেই পড়তে হবে, আর আপনাকেই ভুল অনুরোধ চালিয়ে দেখতে হবে। এখানে মত ভিন্ন আছে: অনেক অভিজ্ঞ দল সরল অ্যাপে নিজের ছোট কোড পছন্দ করে, আবার অনেকে বলে auth-এ নিজের কোড মানেই নিজের ঝুঁকি। দুটি মতেরই যুক্তি আছে।
আরও গভীরে: RFC 9700 (২০২৫, OAuth-এর নিরাপত্তার সেরা অভ্যাস) PKCE, হুবহু redirect মেলা আর implicit বাদ দেওয়ার কথা বলে। RFC 9207 ফেরার পথে iss যোগ করে, যা আমাদের callback-এ দেখা গেছে। Browser-এর JavaScript-এ token না রেখে backend-এ flow চালানোর ধরনকে backend for frontend বলে, যার আলোচনা IETF-এর “OAuth 2.0 for Browser-Based Applications” খসড়ায় আছে। আমাদের Express নকশাটি এই গোত্রের, তাই React-এ কোনো provider token পৌঁছায় না।
পরীক্ষা ও ইন্টারভিউতে:
stateআর PKCE-র পার্থক্য?stateঅ্যাপ মেলায় আর উত্তরটিকে ব্রাউজারের সাথে বাঁধে। PKCE provider মেলায় আর কোডকে login-এর সাথে বাঁধে।nonceকোথায় মেলানো হয়? ID token-এর claim-এ, অ্যাপের দিকে।- Email দিয়ে user চেনা যাবে না কেন? Email বদলায়। স্থায়ী পরিচয় হলো
iss+sub. - Pre-hijacking ঠেকাবেন কীভাবে? দুই দিকের email যাচাই ছাড়া নিজে থেকে জোড়া নয়।
- Login-এর পরে session id বদলান কেন? Session fixation ঠেকাতে।
১০ · AI-এর যুগে social login
এই বিষয়ে AI-এর কোড সবচেয়ে বিপজ্জনক ধরনের: চলে, login হয়, কিন্তু যাচাই অনুপস্থিত থাকে। আর API এত দ্রুত বদলেছে যে পুরোনো কোডও আত্মবিশ্বাসে আসে। নিচের সারিগুলো আমরা আমাদের version-এ চালিয়ে বা docs-এ মিলিয়ে দেখেছি।
| AI যা লেখে | সমস্যা | ঠিক পথ |
|---|---|---|
Issuer.discover(...), generators | openid-client 5-এর API, 6-এ নেই | client.discovery(...) |
/api/auth/* Express 5-এ | Wildcard-এর নিয়ম বদলেছে | /api/auth/*splat |
/api/auth/sign-in/oauth2 | 1.7.15-এ আমাদের run-এ 404 | /api/auth/sign-in/social |
lucia install | Deprecated প্যাকেজ | আগে অবস্থা দেখুন |
where: { email } দিয়ে user ধরে login | sub উপেক্ষা, ভুল মানুষ ঢুকতে পারে | Account-এ (provider, sub) |
skipStateCheck, “error চলে যাবে” | CSRF-এর পাহারা বন্ধ | Error-এর কারণ বের করুন |
trustedProviders সব provider-এ | যাচাই ছাড়া জোড়া হতে পারে | কারণসহ, খুব অল্প |
Token localStorage-এ | XSS হলে ফাঁস | Server-এ session, HttpOnly cookie |
AI-কে কাজ দেওয়ার সময় version আর সীমা আগেই বলে দিন। আর যা-ই হোক, আসল secret কখনো পেস্ট করবেন না:
AI-এর লেখা login কোড মেলানোর চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।
প্র ০১Email তো সবচেয়ে সহজ পরিচয়। তাহলে email দিয়ে user মেলানো এত বিপজ্জনক কেন?
Email-কে স্বাভাবিকভাবেই পরিচয় মনে হয়, কারণ প্রতিটি সাইট তাই করে। কিন্তু OIDC Core ১.০ (২০১৪) এ বিষয়ে পরিষ্কার: iss আর sub একসাথেই ব্যবহারকারীর একমাত্র স্থায়ী পরিচয়, যার ওপর client ভরসা করতে পারে। Email claim একই spec-এ স্পষ্টভাবে অনন্য বলে ধরা হয়নি।
সমস্যা তিন ধরনের। প্রথমত, email বদলায়। মানুষ নতুন ঠিকানা নেন, প্রতিষ্ঠান domain বদলায়। আমাদের মডেলে এমন মানুষ sub দিয়ে একই user থেকেছেন। দ্বিতীয়ত, email পুনর্ব্যবহার হতে পারে। কোনো প্রতিষ্ঠানে একজন কর্মী চলে গেলে তাঁর ঠিকানা পরে অন্যজনকে দেওয়া সম্ভব। Email দিয়ে মেলালে নতুন মানুষটি পুরোনোজনের account পেয়ে যেতে পারেন।
তৃতীয়ত, provider-ভেদে email-এর ভরসা আলাদা। ২০২৩ সালে Descope-এর গবেষকেরা “nOAuth” নামে একটি সমস্যা প্রকাশ করেন: প্রকাশিত প্রতিবেদন অনুযায়ী, Microsoft Entra-র কিছু কনফিগারেশনে email claim যাচাইহীন ও পরিবর্তনযোগ্য হতে পারত, আর যেসব অ্যাপ সেই email দিয়ে user মেলাত, তারা ঝুঁকিতে পড়ত। দোষ শুধু provider-এর ছিল না, দোষ ছিল ধরে নেওয়ার যে email-ই পরিচয়।
তাহলে email-এর কাজ কী? যোগাযোগ, আর সাবধানে linking-এর সূত্র। সেজন্য আমাদের কোডে প্রথম প্রশ্ন সবসময় (provider, sub). Email-এ যাওয়া হয় শুধু নতুন পরিচয় এলে, আর সেখানেও দুই দিকের যাচাই শর্ত।
মূল উপলব্ধি: পরিচয় আর যোগাযোগের ঠিকানা দুটি আলাদা জিনিস। যে মান বদলাতে পারে, তাকে কখনো প্রধান চাবি বানাবেন না।
প্র ০২Pre-hijacking ঠেকাতে linking কড়া করলে ব্যবহারকারী বিরক্ত হন। এই দ্বন্দ্ব কীভাবে সামলাবেন?
Sudhodanan আর Paverd-এর ২০২২ সালের গবেষণা এই সমস্যাকে একটি নাম দেয়। আগে account দখল বলতে মানুষ ভাবত, account তৈরির পরে যা ঘটে। তাঁরা দেখান, ক্ষতিটি মালিক আসার আগেও বসানো যায়, আর আক্রমণকারীর শুধু ভুক্তভোগীর email জানা থাকলেই চলে। ৭৫টি জনপ্রিয় সেবার মধ্যে অন্তত ৩৫টিতে কোনো না কোনো ধরনের ফাঁক মিলেছিল। আমরা যে ধরনটি দেখলাম, সেখানে password আর federated পথ একই account-এ মিশে যায়।
কড়া নিয়মের দাম আছে। সৎ ব্যবহারকারী আগে email দিয়ে signup করে যাচাই করেননি, আজ Google দিয়ে এসে দেখছেন আটকে গেছেন। তিনি কিছুই ভুল করেননি। Conversion কমতে পারে, support-এর অনুরোধ বাড়তে পারে।
সমাধান “নিয়ম ঢিলা করা” নয়, “পথ পরিষ্কার করা”। প্রথমত, signup-এর সময়েই email যাচাই করান, যাতে অযাচাই account কম জমে। দ্বিতীয়ত, ফেলে রাখা অযাচাই account নির্দিষ্ট সময় পরে মুছুন, যা গবেষকদের পরামর্শেও আছে। তৃতীয়ত, আটকালে স্পষ্ট বার্তা দিন আর একটি যাচাই-mail পাঠান। চতুর্থত, আরেকটি নকশা হলো: provider verified হলে local account-এর password বাতিল করে দিয়ে জোড়া। তখন আগের কেউ ঢুকতে পারে না, তবে আসল মালিকের password-ও চলে যায়, তাই তাঁকে জানাতে হবে।
Better Auth-এর ডিফল্টও আমাদের run-এ কড়া পথ নিয়েছে, যা একটি শিক্ষা: ভালো library নিরাপদ পথকে ডিফল্ট রাখে, আর সহজ পথের জন্য আপনাকে সচেতনভাবে option দিতে হয়।
মূল উপলব্ধি: নিরাপত্তা আর সুবিধার দ্বন্দ্ব প্রায়ই নকশার সমস্যা। শর্ত না কমিয়ে ব্যবহারকারীর জন্য বেরোনোর পথ বানান।
প্র ০৩Lucia বন্ধ হলো, Auth.js হাত বদলাল। এই গল্প থেকে library বাছাই নিয়ে কী শেখার আছে?
Lucia একসময় TypeScript জগতে খুব প্রিয় ছিল, কারণ এটি হালকা ছিল আর session-কে সহজ রাখত। প্রকল্পের নিজের সাইট জানায়, এটি মার্চ ২০২৫-এ deprecated হয়। এখন সাইটটি একটি ফাইলের নমুনা কোড দেয়, যা আপনি নিজের project-এ রাখতে পারেন। npm-এ প্যাকেজে এখনো 3.2.2 আছে, সাথে deprecated-এর বার্তা।
Auth.js-এর গল্প আলাদা। NextAuth.js নামে এটি বহু বছর Next.js-এর পরিচিত পথ ছিল। সেপ্টেম্বর ২০২৫-এ Auth.js দল জানায়, এখন এটি রক্ষণ করবে Better Auth দল, আর বেশিরভাগ দলের জন্য তারা Better Auth সুপারিশ করে। নিরাপত্তা ও জরুরি কাজ চলবে বলা হয়েছে। npm-এ এখনো latest হলো 4.24.15, আর v5 বহুদিন ধরে beta, যা নিজেই একটি সংকেত।
এখানে কাউকে দোষ দেওয়া উদ্দেশ্য নয়। Open source রক্ষণ করে মানুষ, আর মানুষ সময় বদলায়। প্রশ্নটি হলো, ঝুঁকিটি কীভাবে ধরবেন। Weekly download দেখে নয়, দেখুন শেষ security release কবে, কতজন maintainer, issue-র উত্তর আসে কি না, আর পিছনে কার অর্থায়ন।
আরেকটি দিক হলো নকশা। আপনার ডেটা যদি সরল table-এ থাকে, library বদলানো কষ্টকর হলেও সম্ভব। তাই library-র পেছনে নিজের ছোট একটি স্তর রাখুন, যেমন getCurrentUser(), যাতে সব route সরাসরি library-কে না ডাকে (পর্ব ২৯-এর service আর repository-র চিন্তা). Hosted সেবাতেও একই প্রশ্ন: password hash কি রপ্তানি করা যায়?
মূল উপলব্ধি: Dependency বেছে নেওয়া মানে তার রক্ষণের ভবিষ্যৎ বেছে নেওয়া। বেরোনোর পথ আগেই ভেবে রাখুন, বিশেষ করে login-এর মতো জায়গায়।
প্র ০৪Login শেষে access token-কেই cookie-তে রেখে session হিসেবে চালালে কী সমস্যা? ID token দিয়ে চালালে?
লোভটি বোঝা যায়: একটি JWT তো হাতেই আছে, আবার session table কেন? কিন্তু প্রতিটি token একটি নির্দিষ্ট প্রাপকের জন্য বানানো, আর এখানেই গোলমাল।
Access token provider-এর API-র জন্য। অনেক provider-এ এটি client-এর পড়ার জন্য নয়, এমনকি JWT না হয়ে একটি অস্বচ্ছ string-ও হতে পারে। আপনার অ্যাপ এটি দিয়ে পরিচয় ধরলে “confused deputy”-র ফাঁদে পড়ে: অন্য অ্যাপের জন্য দেওয়া token-ও আপনার কাছে বৈধ মনে হতে পারে। এই যুক্তি OAuth পর্বে বিস্তারিত আছে।
ID token আপনার জন্যই বানানো, কিন্তু সেটি একটি login ঘটনার প্রমাণ, চলমান session নয়। আমাদের run-এ এর মেয়াদ ছিল ৩৬০০ সেকেন্ড, যা provider ঠিক করেছে। এর পরে কী? ব্যবহারকারীকে আবার পাঠাবেন? আর মাঝপথে যদি role বদলায়, বা ব্যবহারকারী “সব ডিভাইস থেকে বের হতে” চান, স্বাক্ষর করা token-কে ফেরানো যায় না। পর্ব ৪৬-এ JWT-র এই দাম আলাদা করে দেখেছেন।
নিজের session-এ এসব সমস্যা নেই। মেয়াদ আপনার, মুছে ফেলা এক লাইনের কাজ, cookie HttpOnly, আর provider বন্ধ থাকলেও চালু ব্যবহারকারীরা কাজ করতে পারেন। Provider-এর API পরে ডাকতে হলে তখন refresh token লাগবে, যা encrypt করে server-এ রাখতে হবে। সেটি আলাদা প্রয়োজন, শুধু login-এর জন্য নয়।
একটি ব্যতিক্রমও আছে। কিছু single-page অ্যাপ বা service নিজের API-র জন্য আলাদা করে JWT ব্যবহার করে। কিন্তু সেটিও আপনার issuer-এর দেওয়া, provider-এর token নয়।
মূল উপলব্ধি: Token-কে তার প্রাপকের বাইরে ব্যবহার করবেন না। Provider-এর কাজ login-এর মুহূর্তে শেষ, session-এর জীবন আপনার নিয়মে চলে।
প্র ০৫PKCE তৈরি হয়েছিল মোবাইলের জন্য, আর custom scheme তো এখনো চলে। তবু কেন claimed link আর সিস্টেম ব্রাউজারের ওপর এত জোর?
২০১০-এর দশকে মোবাইল app-গুলো প্রায়ই নিজের ভেতরে একটি web view খুলে login দেখাত। এতে app-এর পক্ষে password দেখা সম্ভব ছিল, আর মানুষ ঠিকানা বার দেখে সাইট যাচাই করতে পারতেন না। RFC 8252 (২০১৭, “OAuth 2.0 for Native Apps”) এই পথ থেকে সরে এসে সিস্টেম ব্রাউজারের কথা বলে। বড় provider-রাও পরে embedded web view-তে login সীমিত করেছে, Google-ও তার নীতিতে এই পথ আটকেছে।
ফেরার পথই ছিল দ্বিতীয় সমস্যা। Custom scheme (bazarghar://) কোনো মালিকানা প্রমাণ করে না। একই scheme অন্য app-ও নিবন্ধন করতে পারে, আর তখন কোড ভুল app-এ যেতে পারে। এখানেই PKCE (RFC 7636, ২০১৫) এলো: কোডটি পেলেও verifier ছাড়া ভাঙানো যায় না, আর verifier আছে শুধু আসল app-এর memory-তে।
তাহলে PKCE থাকলে custom scheme নিরাপদ? অনেকটাই, কোড চুরির দিক থেকে। কিন্তু ক্ষতিকর app অন্যভাবেও সমস্যা করতে পারে, যেমন নিজেই একটি flow শুরু করে ব্যবহারকারীকে বিভ্রান্ত করা। Claimed HTTPS link-এ (Android App Links, iOS Universal Links) domain-এর মালিকানা প্রমাণ করতে হয়, তাই ঠিকানাটি একটি নির্দিষ্ট app-এরই হয়। সেই কারণে এটি শক্তিশালী পথ।
Web-এর দিকেও একই চিন্তা এসেছে। RFC 9700 confidential client-এও PKCE সুপারিশ করে, কারণ এটি code injection ঠেকায়। আমাদের Express অ্যাপে secret থাকা সত্ত্বেও PKCE দেওয়া হয়েছে ঠিক এই কারণে।
মূল উপলব্ধি: মোবাইলের জন্য বানানো একটি সমাধান শেষে সবার ডিফল্ট হয়েছে। প্রতিরক্ষা স্তরে স্তরে আসে: সিস্টেম ব্রাউজার, মালিকানা-প্রমাণ করা ঠিকানা, আর PKCE.
অনুশীলন
হিসাব, সমালোচনা, ফল অনুমান আর একটি নিজের মেশিনের কাজ।
-
হিসাব: (ক) ৪৮ byte এলোমেলো মান থেকে base64url verifier বানালে কত অক্ষর হবে, আর সেটি কি RFC-র সীমায় পড়ে? (খ) ২০ byte দিলে কী হবে? (গ) ID token-এ
iat1793558089 আরexp1793561689. মেয়াদ কত মিনিট? (ঘ) Authorization code-এর মেয়াদ ৬০ সেকেন্ড, আর একজন ব্যবহারকারীর ফেরার পথে ৭০ সেকেন্ড লাগল। কী হবে?(ক) ৪৮ × ৮ = ৩৮৪ bit. ⌈৩৮৪ ÷ ৬⌉ = ৬৪ অক্ষর, যা ৪৩ থেকে ১২৮-এর মধ্যে, তাই বৈধ।
(খ) ২০ × ৮ = ১৬০ bit. ⌈১৬০ ÷ ৬⌉ = ২৭ অক্ষর, যা ৪৩-এর কম, তাই RFC-র বাইরে।
(গ) 1793561689 − 1793558089 = ৩৬০০ সেকেন্ড = ৬০ মিনিট.
(ঘ) ভালো করে দেখুন, মেয়াদ ঘড়ি চলে কোড বানানোর পর থেকে, login শুরুর পর থেকে নয়। কোড যদি তৈরি হওয়ার ৭০ সেকেন্ড পরে ভাঙানো হয়, provider
invalid_grantদেবে। ব্যবহারকারীকে “আবার চেষ্টা করুন” বলে নতুন login শুরু করাতে হবে। Login পাতায় বসে থাকার সময় এতে গোনা হয় না। -
নকশা সমালোচনা: AI একটি callback দিল। কমপক্ষে ছয়টি সমস্যা ধরুন।
app.get('/auth/callback', async (req, res) => { const r = await fetch(TOKEN_URL, { method: 'POST', body: new URLSearchParams({ code: req.query.code, client_id: ID, client_secret: 'real-looking-secret', redirect_uri: req.protocol + '://' + req.get('host') + '/auth/callback', grant_type: 'authorization_code' }) }); const t = await r.json(); const claims = JSON.parse(atob(t.id_token.split('.')[1])); let user = await prisma.user.findUnique({ where: { email: claims.email } }); if (!user) user = await prisma.user.create({ data: { email: claims.email } }); req.session.userId = user.id; res.redirect(req.query.next); });stateনেই: CSRF-এর পাহারা অনুপস্থিত।- PKCE নেই:
code_verifierপাঠানোই হয়নি, তাই কোড বসানো ঠেকে না। - ID token যাচাই নেই: শুধু base64 খোলা। স্বাক্ষর,
iss,aud,exp,nonceকিছুই দেখা হয়নি। - Email দিয়ে user চেনা:
subনেই,Accountনেই,email_verifiedদেখা হয়নি। অযাচাই email দিয়েই অন্যের account-এ ঢোকা সম্ভব। - Secret কোডে: Environment-এ থাকা উচিত।
Hostheader থেকে redirect URI: বাইরের মান। নির্দিষ্ট config থেকে নিন।session.regenerateনেই: Session fixation-এর ঝুঁকি।res.redirect(req.query.next): Open redirect. শুধু নিজের path-এর allowlist.- Error handling নেই: Provider error দিলে
t.id_tokenনেই, আর 500 আসবে।
-
ফল অনুমান: শুরুতে
users-এ{id:1, email:'a@x.test', emailVerified:true}আর{id:2, email:'b@x.test', emailVerified:false},accountsখালি। পাতার linking-এর নিয়মে নিচের login-গুলো পরপর হলে প্রতিটির ফল কী, আর শেষেaccounts-এ কয়টি সারি?login({ sub: 's1', email: 'a@x.test', email_verified: false }); login({ sub: 's2', email: 'b@x.test', email_verified: true }); login({ sub: 's3', email: 'c@x.test', email_verified: false }); login({ sub: 's4', email: 'a@x.test', email_verified: true }); login({ sub: 's3', email: 'a@x.test', email_verified: true });- s1: user 1 আছে, কিন্তু provider-এর email যাচাই হয়নি, তাই
provider_email_not_verified. - s2: user 2 আছে, provider ঠিক, কিন্তু local যাচাই হয়নি, তাই
local_email_not_verified. - s3:
c@x.testনেই। নতুন user 3,emailVerified: false,accounts-এ s3. - s4: দুই দিকই যাচাই করা, তাই user 1-এ জোড়া,
accounts-এ s4. - s3 আবার: Email এবার
a@x.test, কিন্তু প্রথম ধাপেইsubপাওয়া গেল, তাই user 3. Email-টি দেখাই হলো না।
শেষে
accounts-এ ২টি সারি: s3 → 3, s4 → 1. - s1: user 1 আছে, কিন্তু provider-এর email যাচাই হয়নি, তাই
-
কমান্ড: নিজের মেশিনে একটি খালি folder-এ
oidc-providerinstall করে পাতারidp.mjsচালান, শুধু নকল ব্যবহারকারী দিয়ে। তারপরcurlদিয়ে discovery আর JWKS পড়ুন। শেষেredirect_uri-র শেষে একটি/যোগ করা authorize URL browser-এ খুলুন। কী দেখবেন?curl -s http://localhost:47401/.well-known/openid-configuration curl -s http://localhost:47401/jwks # browser-এ খুলুন, এক লাইনে: # http://localhost:47401/auth?client_id=bazarghar-local&response_type=code # &scope=openid&redirect_uri=http://localhost:47402/auth/callback/প্রথমটিতে
issuer,jwks_uriইত্যাদি JSON, দ্বিতীয়টিতেkeys-এর একটি তালিকা পাবেন। Authorize URL-এ provider 400 দেবে আর পাতায়invalid_redirect_uriদেখাবে, ঠিক যেমন আমাদের run-এ। কোডের সাথে মিলিয়ে দেখুন, provider কোনো redirect করেনি। কারণ ভুল ঠিকানায় ফেরত পাঠানোই তো বিপদ। Server বন্ধ করবেন নিজের terminal থেকে Ctrl+C চেপে।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমExpress অ্যাপে একটি OIDC login-এর ধাপগুলো সাজান (▲▼ চেপে সরান)
- Verifier,
stateআরnonceবানিয়ে session-এ রাখা - ব্রাউজারকে provider-এ redirect করা
- Provider-এ login আর consent
- Callback-এ
stateমেলানো - কোড আর verifier দিয়ে সরাসরি token আনা, ID token যাচাই
- User খুঁজে session নতুন করে বানানো
OIDC login-এ state মানটি প্রধানত কে মেলায়, আর কী ঠেকাতে?
- Provider মেলায়, কোড চুরি ঠেকাতে
- Client মেলায়, CSRF ঠেকাতে
- Client ID token-এ মেলায়, token-এর replay ঠেকাতে
- Provider hash মেলায়, verifier যাচাই করতে
৩২ byte এলোমেলো মানকে base64url করলে কত অক্ষরের PKCE verifier পাওয়া যায়?
- ৩২
- ৬৪
- ৪৩
- ১২৮
একটি Account table-এ login-এর পরিচয় ধরে রাখার সবচেয়ে ঠিক unique চাবি কোনটি?
emailaccessTokensubএকা(provider, providerAccountId)একসাথে
বাজারঘরে একটি অযাচাই password account আছে x@test.example ঠিকানায়। কেউ provider দিয়ে login করলেন, যেখানে email_verified সত্য আর email একই। নিরাপদ আচরণ কোনটি?
- নিজে থেকে জোড়া না দিয়ে আগে মালিকানা নিশ্চিত করা
- জোড়া দেওয়া, কারণ provider যাচাই করেছে
- একই email-এ দ্বিতীয় user বানানো
- পুরোনো account সাথে সাথে মুছে ফেলা
নিবন্ধিত redirect URI https://bazarghar.example/auth/callback. Staging সাইট থেকে অনুরোধে এলো https://staging.bazarghar.example/auth/callback. Provider কী করবে?
- Subdomain বলে মেনে নেবে
- Error দেবে, staging-এর ঠিকানা আলাদা করে নিবন্ধন লাগবে
- Error-সহ ওই ঠিকানায় ফেরত পাঠাবে
- নিবন্ধিত production ঠিকানায় পাঠাবে
মোবাইল app-এ OAuth login বানানোর সবচেয়ে ঠিক পথ কোনটি?
- App-এর ভেতরে client secret রেখে code flow
- App-এর embedded web view-তে login পাতা
- সিস্টেম ব্রাউজার, PKCE, আর claimed HTTPS link
- Implicit flow, যাতে backend না লাগে
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- Provider জানায়, session আপনার: Login শেষে
regenerateকরে নিজের session. - তিন মান, তিন কাজ: Verifier provider মেলায়,
stateআরnonceঅ্যাপ মেলায়। Library-কে তিনটিই দিন। - চাবি
(provider, sub): Email শুধু linking-এর সূত্র, আর সেখানে দুই দিকের যাচাই শর্ত। - Redirect URI হুবহু: শেষের
/,localhostবনাম127.0.0.1, সব আলাদা। প্রতিটি পরিবেশে আলাদা client. - আগে অবস্থা দেখুন: Library-র version আর রক্ষণ যাচাই করুন, আর ভুল অনুরোধ চালিয়ে দেখুন থামে কি না।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৪৮ · Permission — role, ownership আর frontend-backend দুই দিকের যাচাইপরবর্তী পর্বকে ঢুকেছেন জানা গেল, এবার তিনি কী করতে পারবেন, তা server-এ যাচাই করবেন।
- ফুল-স্ট্যাক ওয়েব ফ্রেমওয়ার্ক · কোর্স হোম৯৩টি পর্বপুরো কোর্সের রোডম্যাপ। পরের পর্ব ৪৮: Permission.
- Cybersecurity · OAuth আর OpenID Connectগভীরে যানচার ভূমিকা, flow-র তত্ত্ব, consent phishing.
- Cybersecurity · Session আর tokenপাশের ট্র্যাকSession fixation আর token-এর ঝুঁকি।
- Cybersecurity · CSRF আর cookieপাশের ট্র্যাকSameSite আর login CSRF.
- পর্ব ৪১ · সম্পর্কআগের পর্বOne-to-many আর
Accountমডেলের ভিত্তি। - পর্ব ৩৯ · Prismaআগের পর্বSchema, client আর P2002.