ספק זהויות · שרת הרשאות OAuth2
שרת אימות מרכזי להתקנה עצמית ב-TypeScript, Express ו-MongoDB — OAuth2 עם PKCE, מפתחות מתחלפים שמתפרסמים ב-JWKS, רוטציית refresh token עם זיהוי שימוש חוזר, הרשאות מבוססות תפקיד ויומן ביקורת מלא.
מאגר פרטי — הקוד אינו פומבי
כל שירות חדש התחיל מבנייה מחדש של אותה שכבת אימות, בכל פעם קצת אחרת. באחד היו refresh tokens, בשני לא. אחד תיעד ניסיונות התחברות שנכשלו, השני בלע אותם בשקט. הרשאות היו שדה טקסט בשלוש צורות שונות. כשמדיניות סיסמאות הייתה צריכה להשתנות, היא הייתה צריכה להשתנות בכל שירות שהחזיק עותק משלו — והעותקים כבר מזמן נפרדו זה מזה.
מה שהיה צריך באמת הוא שרת הרשאות אחד שכל אפליקציה תוכל להאציל אליו, שניתן להתקנה עצמית, ובלי תמחור לפי מספר משתמשים.
שרת הרשאות ב-TypeScript מעל Express ו-MongoDB, שמממש את זרימת Authorization Code עם PKCE, מאוגד מול גוגל, ומנפיק JWT חתומים במפתחות שמתחלפים אוטומטית ומתפרסמים בנקודת JWKS.
תרשים רצף: הלקוח מייצר verifier וגוזר ממנו אתגר S256, פונה לנקודת ההרשאה, Signet קושר את האתגר ל-state ומפנה לגוגל, החזרה מזהה את המשתמש, והחלפת הטוקן מצליחה רק כאשר ה-SHA-256 של ה-verifier תואם לאתגר השמור.
החלק ששווה לקרוא הוא החלפת הטוקן. הלקוח שולח את ה-verifier רק בשלב ההחלפה, והשרת משווה את הגיבוב שלו לאתגר ששמר בשלב ההרשאה:
const verifyPkce = (record: IAuthorizationCode, verifier: string): void => {
if (record.challengeMethod !== 'S256') {
throw new OAuthError('invalid_request', 'unsupported code_challenge_method');
}
const digest = createHash('sha256').update(verifier).digest();
const expected = Buffer.from(record.codeChallenge, 'base64url');
if (digest.length !== expected.length || !timingSafeEqual(digest, expected)) {
throw new OAuthError('invalid_grant', 'code_verifier does not match');
}
};השתמשתי ב-timingSafeEqual ולא ב-=== מפני שההשוואה היא מול קלט שמגיע מהתוקף, ובדיקת האורך קודמת מפני ש-timingSafeEqual זורק שגיאה על אורך שונה במקום להחזיר false.
כל רענון מנפיק יורש ומוציא את קודמו משימוש. אם טוקן שהוצא משימוש מוצג שוב, זו לא תחרות שצריך לסבול — זו עדות לכך שהטוקן הועתק.
תרשים מצבים: טוקן שהונפק נמצא במצב פעיל, הצגתו מחליפה אותו ומנפיקה יורש, הצגה חוזרת של טוקן שכבר הוחלף מסמנת אותו כפרוץ ושוללת את כל שרשרת הטוקנים, וטוקן שלא נעשה בו שימוש פשוט פג.
const rotate = async (presented: string): Promise<ITokenPair> => {
const record = await refreshTokens.findByHash(hash(presented));
if (!record) {
throw new OAuthError('invalid_grant', 'unknown refresh token');
}
if (record.rotatedAt !== null) {
await refreshTokens.revokeFamily(record.familyId, 'reuse_detected');
await auditLog.record('refresh.reuse_detected', { familyId: record.familyId, subject: record.subject });
throw new OAuthError('invalid_grant', 'refresh token already used');
}
return issueSuccessor(record);
};הטוקנים נשמרים כגיבובים, כך שהעתק של מסד הנתונים אינו מוסר אישורים שמישים. נשללת כל השרשרת ולא הטוקן הבודד, מפני שברגע שטוקן הועתק אין דרך לדעת מי משני הקוראים הוא הלגיטימי.
מפתחות החתימה מתחלפים לפי לוח זמנים. מפתחות פרטיים מוצפנים באחסון; מפתחות ציבוריים מוגשים מנקודת ה-JWKS, ומפתח שיוצא משימוש נשאר מפורסם למשך זמן ארוך יותר מאורך החיים המרבי של טוקן גישה, כך ששום אימות לא נכשל באמצע.
תרשים זרימה: נוצר זוג מפתחות RSA, המפתח הפרטי מוצפן ונשמר, המפתח הציבורי מתפרסם בנקודת JWKS והופך למפתח החתימה, רוטציה מקדמת מפתח חדש בזמן שהמפתח היוצא עדיין מוגש בחלון חסד, ורק לאחר שחלף אורך החיים הארוך ביותר של טוקן הוא מוסר ומושמד.
הרשאות מבוססות תפקידים עם תפקידים והרשאות שנפתרים בזמן הנפקת הטוקן, ניהול סשנים עם שלילה מפורשת, מדיניות סיסמאות לפי NIST 800-63B — אורך במקום כללי הרכב, ובדיקה מול סיסמאות שדלפו במקום החלפה כפויה כל רבעון — ויומן ביקורת שמוסיף בלבד ומכסה כל החלטת אימות.
זו השאלה ההוגנת, ולרוב הפרויקטים התשובה היא להשתמש ב-Auth0. לקנות זהות היא בדרך כלל ההחלטה הנכונה: זה רכיב קריטי לאבטחה עם שטח פנים גדול, וספק שמתמחה בזה יתחזק אותו טוב יותר ממך.
שלושה דברים דחפו לכיוון השני כאן.
עקומת העלות הצביעה לכיוון הלא נכון. זהות מנוהלת זולה בכמה מאות משתמשים והופכת למס לפי משתמש בדיוק כשמוצר מצליח. עבור אוסף שירותים פנימיים עם מספר משתמשים לא חסום ובלי הכנסה לכל משתמש, מודל התמחור היה הפוך.
זה היה חייב להיות בתשתית שלנו. שירות חיצוני בנתיב האימות פירושו תלות חיצונית בכל התחברות, ונתוני זהות שיוצאים מתשתית שבשליטתנו. זה לא היה מקובל בסביבה שבה זה רץ.
התקן הוא הממשק האמיתי. מה ששווה להיות תלוי בו הוא OAuth2 ו-OIDC, לא ה-SDK של ספק מסוים. מימוש המפרט ישירות אומר שכל לקוח משתמש בספרייה סטנדרטית, ושמעבר עתידי לספק מנוהל הוא שינוי הגדרות ולא כתיבה מחדש של כל אפליקציה.
העלות הכשרה: האבטחה של הקוד הזה עליי. זה מוגן רק בגלל מערך הבדיקות — מעל 800 בדיקות ב-100% כיסוי — ומפני שהיקף המימוש נשאר צר בכוונה. מימשתי את הזרימות שנדרשו בפועל, לא את כל שטח הפנים של המפרט. בלי implicit flow, בלי resource owner password credentials, בלי device flow. כל זרימה שלא מימשתי היא זרימה שאני לא צריך לשמור מאובטחת.
רוטציה עולה כתיבה למסד בכל רענון ומסבכת מעט את הלקוח, שצריך להתמודד עם טוקן שמשתנה תחתיו. מה שהיא קונה זה זיהוי: בלי רוטציה, refresh token גנוב הוא סשן שקט ובלתי מוגבל בזמן. איתה, הגניבה מכריזה על עצמה ברגע שאחד הצדדים משתמש בטוקן מיושן.
טוקנים אטומים קלים יותר לשלילה — מוחקים שורה. JWT ניתנים לאימות בלי סבב רשת, וזו הייתה הדרישה. הפשרה היא שטוקן גישה נשאר תקף עד שהוא פג, ולכן אורכי החיים של טוקני גישה קצרים והשלילה מתרחשת בגבול הרענון. הסמכות ארוכת-הטווח יושבת ב-refresh token, שכן כן נבדק מול מסד הנתונים בכל שימוש.
בעיקר החלטת התאמה: השירותים סביב כבר היו על MongoDB, ורשומות זהות הן במבנה מסמכי. זה עלה לי בהבטחות טרנזקציוניות שהייתי מקבל בחינם ב-PostgreSQL, ולכן רוטציית הטוקן כתובה כעדכון מותנה על המסמך של הטוקן עצמו ולא כקריאה ואז כתיבה.
בפרודקשן, משרת אפליקציות אמיתיות כשכבת האימות שלהן. שירותים לא מחזיקים יותר קוד אימות משלהם; הם מאמתים JWT מול נקודת ה-JWKS וקוראים תפקידים מתוך הטוקן.
מה שהייתי בודק מחדש: יומן הביקורת וחנות הטוקנים חולקים מסד נתונים, כך שתקופת כתיבה כבדה ליומן מתחרה באימות הטוקנים. הפרדה ביניהם היא השינוי הראשון שהייתי עושה אם התעבורה הייתה גדלה.