OAuth 2.0 on tänä päivänä käytännössä pakollinen osa jokaista Node.js-sovellusta, jossa käyttäjät kirjautuvat sisään Google-, GitHub- tai Microsoft-tileillä. Passport.js on de facto -standardi OAuth 2.0 -todennuksen toteuttamiseen Node.js:ssä: kirjasto kerää yli 1,4 miljoonaa viikoittaista npm-latausta ja on tuotantokäytössä sadoissa tuhansissa sovelluksissa maailmanlaajuisesti. Tässä oppaassa käyt läpi 12 vaihetta toimivaan OAuth 2.0 -toteutukseen Express.js-sovelluksessa, PKCE-turvaprotokolla mukaan lukien, Redis-pohjainen istuntohallinta sekä tuotantovalmis uloskirjautuminen.

Mitä OAuth 2.0 tarkoittaa ja miksi se on välttämätön

OAuth 2.0 (RFC 6749) on valtuutuskehys, joka mahdollistaa kolmansien osapuolten sovellusten rajoitetun pääsyn käyttäjätilille ilman salasanan jakamista. Protokolla erottaa todennuksen (kuka olet) ja valtuutuksen (mitä saat tehdä) toisistaan selkeästi. Kun käyttäjä kirjautuu Node.js-sovellukseesi Google-tilillään, Google toimii valtuutuspalvelimena ja sovelluksesi resurssien käyttäjänä.

Perinteiseen salasanapohjaiseen todennukseen verrattuna OAuth 2.0 tarjoaa kolme keskeistä etua. Ensiksi sovelluksesi ei koskaan käsittele käyttäjän salasanaa, joten tietomurtojen vakavuus pienenee merkittävästi. Toiseksi käyttäjät kirjautuvat sisään yhdellä klikkauksella olemassa olevilla tileillä ilman uuden salasanan luomista. Kolmanneksi käyttöoikeuksia rajataan tarkasti: sovellus pyytää vain tarvitsemansa scope-arvot, kuten profile ja email.

OpenID Connect (OIDC) rakentuu OAuth 2.0:n päälle ja lisää todennuskerroksen ID-tokenin muodossa. Google, Microsoft ja GitHub tukevat kaikki OIDC-laajennusta, joten sovelluksesi saa sekä valtuutuksen (access token) että identiteettitiedon (ID token) yhdellä virralla. Passport.js abstrahoi tämän monimutkaisuuden yksinkertaiseksi rajapinnaksi.

Esivaatimukset ja versiot

Ennen aloittamista varmista, että kehitysympäristösi täyttää seuraavat vaatimukset. Versioyhteensopivuus on kriittinen, sillä Passport.js:ssä tapahtui merkittäviä muutoksia 0.6.0-versiosta alkaen:

VaatimusMinimi versioSuositeltuHuomio
Node.js18.x LTS22.x LTSPitkäaikaistuki vaaditaan
npm9.x10.xPackage lock v3
passport0.6.00.7.0req.logout() vaatii callbackin 0.6+ versioissa
passport-google-oauth202.0.02.0.0Vakain Google-strategia
passport-github20.3.00.3.0GitHub OAuth2 -strategia
express-session1.17.31.18.1Tietoturvapäivitykset sisältyvät
connect-redis7.x8.xRedis-istuntotallennus
Redis6.x7.xDocker tai paikallinen asennus

Tarvitset myös Google Cloud Console -tilin OAuth2-tunnusten luomiseen. GitHub Developer Settings riittää GitHub-strategiaa varten. Tässä oppaassa Google-strategia toimii pääesimerkkinä, mutta samat periaatteet pätevät kaikkiin Passport.js-strategioihin.

Vaihe 1: Google Cloud -projekti ja OAuth2-tunnukset

Google-pohjaisen OAuth2-todennuksen käyttöönotto alkaa Google Cloud Consolesta. Avaa console.cloud.google.com, luo uusi projekti tai valitse olemassa oleva. Siirry kohtaan “APIs & Services” ja valitse “Credentials”.

Klikkaa “Create Credentials” ja valitse “OAuth client ID”. Valitse sovelluksen tyypiksi “Web application” ja anna sille kuvaava nimi. Lisää valtuutetut uudelleenohjaus-URI:t:

  • Kehitys: http://localhost:3000/auth/google/callback
  • Tuotanto: https://sovelluksesi.fi/auth/google/callback

Google luo Client ID:n ja Client Secretin. Tallenna nämä heti, sillä Client Secret näytetään vain kerran. Nämä tunnukset eivät saa koskaan päätyä versionhallintaan tai lokitiedostoihin. Jos tunnukset vuotavat, peruuta ne välittömästi Cloud Consolessa ja luo uudet.

Ota käyttöön tarvittavat Google API:t: “People API” on nykysuosituksen mukainen rajapinta käyttäjäprofiilitietojen hakemiseen. Aktivoi se “APIs & Services” / “Library” -valikosta ennen testaamista, muuten callback epäonnistuu 403-virheellä.

Vaihe 2: Projektin alustaminen ja pakettien asennus

Luo uusi Node.js-projekti ja asenna tarvittavat riippuvuudet. Projekti käyttää Express.js-kehystä yhdistettynä Passport.js:ään ja Redis-pohjaiseen istuntotallennukseen tuotantovalmiuden varmistamiseksi:

mkdir passport-oauth2-demo
cd passport-oauth2-demo
npm init -y

# Ydinriippuvuudet
npm install express passport passport-google-oauth20 express-session

# GitHub-strategia (valinnainen)
npm install passport-github2

# Tuotantoistunto Redis-tallennuksella
npm install connect-redis redis

# Ympäristömuuttujat
npm install dotenv

# Kehitystyökalut
npm install --save-dev nodemon

Luodaan projektirakenne, joka noudattaa MVC-periaatetta ja pitää autentikaatiologiikan erillään reiteistä:

passport-oauth2-demo/
├── src/
│   ├── config/
│   │   ├── passport.js      # Passport-strategiat
│   │   └── session.js       # Istuntokonfiguraatio
│   ├── middleware/
│   │   └── auth.js          # Todennuksen väliohjelmat
│   ├── routes/
│   │   ├── auth.js          # OAuth-reitit
│   │   └── protected.js     # Suojatut reitit
│   └── app.js               # Express-sovellus
├── .env                     # Ympäristömuuttujat (ei git:iin)
├── .env.example             # Esimerkki muuttujista
├── .gitignore
└── package.json

Lisää package.json-tiedostoon käynnistyskomennot:

{
  "scripts": {
    "start": "node src/app.js",
    "dev": "nodemon src/app.js"
  }
}

Vaihe 3: Ympäristömuuttujat ja salaisuuksien hallinta

Luo .env-tiedosto projektin juureen. Tärkeää: lisää .env välittömästi .gitignore-tiedostoon ennen ensimmäistäkään git-commitia. Google Client Secret -vuodot ovat yksi yleisimmistä OAuth2-tietoturvavuodoista, ja GitHubin automaattiset skannerit havaitsevat ne minuuteissa.

# .env - EI KOSKAAN VERSIONHALLINTAAN
NODE_ENV=development
PORT=3000

# Google OAuth2
GOOGLE_CLIENT_ID=123456789-abcdefghijklmnop.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-salainenAvain123

# GitHub OAuth2 (valinnainen)
GITHUB_CLIENT_ID=Ov23liYourClientId
GITHUB_CLIENT_SECRET=ghsec_yourSecretHere

# Istunnon salainen avain (vähintään 32 merkkiä, satunnainen)
SESSION_SECRET=satunnainen-salainen-avain-pitaa-olla-vahintaan-64-merkki

# Redis-yhteys
REDIS_URL=redis://localhost:6379

# Sovelluksen URL (callback-URL:ien generointiin)
APP_URL=http://localhost:3000

Session-salaisuuden tulee olla kryptografisesti satunnainen merkkijono. Generoi se Node.js:n crypto-moduulilla:

node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
# Esimerkki tulosteesta:
# 8f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3...

Luo myös .env.example-tiedosto versionhallintaan. Se näyttää vaaditut muuttujat ilman todellisia arvoja, jolloin tiimin muiden jäsenten on helppo konfiguroida kehitysympäristönsä.

Vaihe 4: Redis-istuntopalvelimen konfiguraatio

Tuotantosovelluksessa istuntoja ei tule tallentaa muistiin, koska palvelimen uudelleenkäynnistys poistaa kaikki aktiiviset istunnot. Redis on suosituin Node.js-istuntotallennus: alle 1 millisekunnin latenssi, automaattinen TTL-vanheneminen ja klusterointituki.

// src/config/session.js
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');

async function createSessionConfig() {
  const redisClient = createClient({
    url: process.env.REDIS_URL || 'redis://localhost:6379',
  });

  redisClient.on('error', (err) => {
    console.error('Redis-virhe:', err);
  });

  await redisClient.connect();
  console.log('Redis-yhteys muodostettu');

  const store = new RedisStore({
    client: redisClient,
    prefix: 'sess:',
    ttl: 86400,  // 24 tuntia sekunneissa
  });

  return session({
    store,
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    name: '__session',  // Ei oletusarvoista 'connect.sid' nimeä
    cookie: {
      secure: process.env.NODE_ENV === 'production',
      httpOnly: true,
      sameSite: 'lax',
      maxAge: 24 * 60 * 60 * 1000,  // 24 tuntia millisekunteina
    },
  });
}

module.exports = { createSessionConfig };

Kolme kriittistä cookie-asetusta tuotannossa: secure: true pakottaa HTTPS-yhteyden istuntoevästeelle, httpOnly: true estää JavaScript-pääsyn evästeeseen (XSS-suoja) ja sameSite: ‘lax’ suojaa CSRF-hyökkäyksiltä salliein silti normaalit sivustolinkit. Aseta resave: false ja saveUninitialized: false estääksesi turhia Redis-kirjoituksia.

Vaihe 5: Passport.js-konfiguraatio Google-strategialla

Passport.js:n konfiguraatio koostuu kolmesta osasta: strategian määrittelystä, käyttäjän serialisoinnista istuntoon ja deserialioinnista takaisin pyyntöolioon. Tämä kolmijako on Passport.js:n keskeinen arkkitehtuurinen päätös, joka mahdollistaa istuntopohjaisen OAuth2-todennuksen.

// src/config/passport.js
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;

// Käyttäjätietokanta (kehityksessä Map, tuotannossa SQL/NoSQL-tietokanta)
const users = new Map();

passport.use(
  new GoogleStrategy(
    {
      clientID: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
      callbackURL: `${process.env.APP_URL}/auth/google/callback`,
      scope: ['openid', 'profile', 'email'],
      // PKCE aktivoitu (RFC 7636 - suositeltava 2025-2026)
      pkce: true,
      state: true,
    },
    async (accessToken, refreshToken, profile, done) => {
      try {
        // Etsi tai luo käyttäjä tietokannasta
        let user = users.get(profile.id);

        if (!user) {
          user = {
            id: profile.id,
            displayName: profile.displayName,
            email: profile.emails?.[0]?.value,
            photo: profile.photos?.[0]?.value,
            provider: 'google',
            createdAt: new Date().toISOString(),
          };
          users.set(profile.id, user);
          console.log('Uusi käyttäjä rekisteröity:', user.email);
        } else {
          // Päivitä kirjautumisaika
          user.lastLogin = new Date().toISOString();
        }

        return done(null, user);
      } catch (error) {
        return done(error, null);
      }
    }
  )
);

// Serialisointi: vain käyttäjä-ID tallennetaan istuntoon
passport.serializeUser((user, done) => {
  done(null, user.id);
});

// Deserialiointi: ID muunnetaan käyttäjäolioksi joka pyynnöllä
passport.deserializeUser(async (id, done) => {
  try {
    const user = users.get(id);
    done(null, user || false);
  } catch (error) {
    done(error, null);
  }
});

module.exports = passport;

Kaksi kriittistä asetusta Google-strategiassa: pkce: true aktivoi PKCE-protokollan (Proof Key for Code Exchange, RFC 7636), joka estää authorization code -vaihdon kaappaamishyökkäykset. state: true generoi automaattisesti CSRF-suojaavan state-parametrin, joka tarkistetaan callback-vaiheessa. Ilman näitä asetuksia OAuth2-implementaatio on altis tunnetuille hyökkäyksille.

Vaihe 6: OAuth2-reittien konfiguraatio

OAuth2-virta vaatii kaksi reittiä: käynnistysreitin, joka ohjaa käyttäjän Googlen kirjautumissivulle, ja callback-reitin, johon Google palauttaa käyttäjän todennuksen jälkeen. Näiden reittien oikea toteutus on kriittinen tietoturvan kannalta.

// src/routes/auth.js
const express = require('express');
const passport = require('../config/passport');
const router = express.Router();

// Vaihe 1: Käynnistä OAuth2-virta - ohjaa Googlen kirjautumissivulle
router.get(
  '/google',
  passport.authenticate('google', {
    scope: ['openid', 'profile', 'email'],
    // Pakota tilin valinta joka kerta
    prompt: 'select_account',
  })
);

// Vaihe 2: Google palauttaa käyttäjän tänne todennuksen jälkeen
router.get(
  '/google/callback',
  passport.authenticate('google', {
    failureRedirect: '/auth/virhe',
    failureMessage: true,
  }),
  (req, res) => {
    // Onnistunut todennus - ohjaa tallennettuun osoitteeseen tai dashboardiin
    const redirectTo = req.session.returnTo || '/dashboard';
    delete req.session.returnTo;
    res.redirect(redirectTo);
  }
);

// Uloskirjautuminen - kolme pakollista vaihetta
router.post('/logout', (req, res, next) => {
  req.logout((err) => {
    if (err) return next(err);
    // Tuhoa koko istunto Redis-tallennuksesta
    req.session.destroy((destroyErr) => {
      if (destroyErr) {
        console.error('Istunnon tuhoaminen epäonnistui:', destroyErr);
      }
      // Poista istuntoevaste selaimesta
      res.clearCookie('__session');
      res.redirect('/');
    });
  });
});

// Virhesivu todennusepäonnistumisille
router.get('/virhe', (req, res) => {
  res.status(401).json({
    virhe: 'Todennus epäonnistui',
    viesti: req.session.messages?.[0] || 'Tuntematon virhe',
  });
});

module.exports = router;

Uloskirjautumisessa on kolme pakollista vaihetta: req.logout() poistaa käyttäjän Passport-istunnosta, req.session.destroy() tuhoaa koko istunnon Redis-tallennuksesta ja res.clearCookie() poistaa istuntoevästeen selaimesta. Kaikkien kolmen vaiheen suorittaminen on pakollista täydelliselle ja turvalliselle uloskirjautumiselle.

Vaihe 7: Todennuksen väliohjelmisto suojatuille reiteille

Luo uudelleenkäytettävä väliohjelmisto, joka tarkistaa käyttäjän todennuksen ennen suojatun sisällön näyttämistä. Hyvin toteutettu väliohjelmisto tallentaa alkuperäisen URL:n ja ohjaa käyttäjän sinne kirjautumisen jälkeen, parantaen merkittävästi käyttökokemusta.

// src/middleware/auth.js

// Vaatii kirjautumisen - ohjaa OAuth2-kirjautumiseen jos ei kirjautunut
function requireAuth(req, res, next) {
  if (req.isAuthenticated()) {
    return next();
  }
  // Tallenna alkuperäinen URL paluua varten
  req.session.returnTo = req.originalUrl;
  res.redirect('/auth/google');
}

// Vaatii tietyn roolin tai oikeuden
function requireRole(role) {
  return (req, res, next) => {
    if (!req.isAuthenticated()) {
      req.session.returnTo = req.originalUrl;
      return res.redirect('/auth/google');
    }
    if (req.user?.role !== role) {
      return res.status(403).json({ virhe: 'Riittämättömät käyttöoikeudet' });
    }
    next();
  };
}

// REST API -pyynöille: palauta 401 JSON, ei uudelleenohjausta
function requireAuthAPI(req, res, next) {
  if (req.isAuthenticated()) {
    return next();
  }
  res.status(401).json({
    virhe: 'Todennus vaaditaan',
    kirjautuminenUrl: '/auth/google',
  });
}

module.exports = { requireAuth, requireRole, requireAuthAPI };

Vaihe 8: Pääsovelluksen kokoaminen

Kokoa kaikki osat yhteen app.js-tiedostossa. Väliohjelmoston järjestys on kriittinen: istunnon täytyy olla alustettuna ennen Passport.js:ää, ja Passport.js ennen reittejä. Väärässä järjestyksessä sovellus kaatuu tai istunnot eivät toimi.

// src/app.js
require('dotenv').config();
const express = require('express');
const passport = require('./config/passport');
const { createSessionConfig } = require('./config/session');
const authRoutes = require('./routes/auth');
const { requireAuth, requireAuthAPI } = require('./middleware/auth');

async function startApp() {
  const app = express();

  // Perusväliohjelmat
  app.use(express.json());
  app.use(express.urlencoded({ extended: true }));

  // Luottamukselliset proxy-otsikot (Nginx / load balancerin takana)
  if (process.env.NODE_ENV === 'production') {
    app.set('trust proxy', 1);
  }

  // Istunto - OLTAVA ennen Passport.js:ää
  const sessionMiddleware = await createSessionConfig();
  app.use(sessionMiddleware);

  // Passport.js alustus - OLTAVA istunnon jälkeen
  app.use(passport.initialize());
  app.use(passport.session());

  // Reitit
  app.use('/auth', authRoutes);

  // Julkinen etusivu
  app.get('/', (req, res) => {
    if (req.isAuthenticated()) {
      return res.json({
        viesti: `Tervetuloa, ${req.user.displayName}!`,
        kayttaja: {
          nimi: req.user.displayName,
          sahkoposti: req.user.email,
        },
      });
    }
    res.json({
      viesti: 'Kirjaudu sisään Google-tilillä',
      kirjautuminenUrl: '/auth/google',
    });
  });

  // Suojattu dashboard - vaatii kirjautumisen
  app.get('/dashboard', requireAuth, (req, res) => {
    res.json({
      viesti: 'Tervetuloa suojatulle sivulle',
      kayttaja: req.user,
    });
  });

  // Suojattu REST API -päätepiste
  app.get('/api/profiili', requireAuthAPI, (req, res) => {
    res.json({ kayttaja: req.user });
  });

  // Virhekäsittely
  app.use((err, req, res, next) => {
    console.error('Sovellusvirhe:', err.message);
    res.status(500).json({ virhe: 'Sisäinen palvelinvirhe' });
  });

  const PORT = process.env.PORT || 3000;
  app.listen(PORT, () => {
    console.log(`Palvelin käynnissä: http://localhost:${PORT}`);
    console.log(`Kirjaudu sisään: http://localhost:${PORT}/auth/google`);
  });
}

startApp().catch(console.error);

Vaihe 9: Testaus ja odotettu tulos

Käynnistä Redis paikallisesti Docker-kontissa ja testaa koko autentikaatiovirta järjestelmällisesti ennen tuotantoon siirtymistä:

# Käynnistä Redis Docker-kontissa
docker run -d -p 6379:6379 --name redis-dev redis:7-alpine

# Vahvista Redis-yhteys
redis-cli ping
# PONG

# Käynnistä sovellus kehitystilassa
npm run dev

# Odotettu terminaalituloste:
# Redis-yhteys muodostettu
# Palvelin käynnissä: http://localhost:3000
# Kirjaudu sisään: http://localhost:3000/auth/google

# Testaa suojattu API ilman kirjautumista
curl http://localhost:3000/api/profiili
# {"virhe":"Todennus vaaditaan","kirjautuminenUrl":"/auth/google"}

# Testaa julkinen etusivu
curl http://localhost:3000/
# {"viesti":"Kirjaudu sisään Google-tilillä","kirjautuminenUrl":"/auth/google"}

Avaa selain ja siirry osoitteeseen http://localhost:3000/auth/google. Sovellus ohjaa sinut Googlen kirjautumissivulle. Kirjautumisen jälkeen Google palauttaa sinut osoitteeseen /dashboard ja saat vastauksen:

{
  "viesti": "Tervetuloa suojatulle sivulle",
  "kayttaja": {
    "id": "112233445566778899",
    "displayName": "Matti Meikäläinen",
    "email": "[email protected]",
    "photo": "https://lh3.googleusercontent.com/a/ACg8ocJ...",
    "provider": "google",
    "createdAt": "2026-06-20T10:30:00.000Z",
    "lastLogin": "2026-06-20T11:45:22.000Z"
  }
}

Vaihe 10: GitHub-strategian lisääminen rinnakkaiseksi tarjoajaksi

Useimmat sovellukset tukevat useita OAuth2-tarjoajia. GitHub-strategian lisääminen Passport.js:ään on suoraviivaista. Ensin luo OAuth-sovellus GitHubissa: Settings, Developer settings, OAuth Apps, New OAuth App. Callback URL:ksi aseta http://localhost:3000/auth/github/callback.

// Lisää src/config/passport.js -tiedostoon Google-strategian jälkeen
const GitHubStrategy = require('passport-github2').Strategy;

passport.use(
  new GitHubStrategy(
    {
      clientID: process.env.GITHUB_CLIENT_ID,
      clientSecret: process.env.GITHUB_CLIENT_SECRET,
      callbackURL: `${process.env.APP_URL}/auth/github/callback`,
      scope: ['user:email'],
    },
    async (accessToken, refreshToken, profile, done) => {
      try {
        // GitHub-sähköposti voi olla yksityinen - hae emails-taulukosta
        const email = profile.emails?.find(e => e.primary)?.value
                   || profile.emails?.[0]?.value;

        const userId = `github_${profile.id}`;
        let user = users.get(userId);

        if (!user) {
          user = {
            id: userId,
            displayName: profile.displayName || profile.username,
            email,
            photo: profile.photos?.[0]?.value,
            provider: 'github',
            githubUsername: profile.username,
            createdAt: new Date().toISOString(),
          };
          users.set(userId, user);
        }

        return done(null, user);
      } catch (error) {
        return done(error, null);
      }
    }
  )
);

// Lisää src/routes/auth.js -tiedostoon GitHub-reitit:
router.get('/github', passport.authenticate('github'));

router.get(
  '/github/callback',
  passport.authenticate('github', { failureRedirect: '/auth/virhe' }),
  (req, res) => {
    const redirectTo = req.session.returnTo || '/dashboard';
    delete req.session.returnTo;
    res.redirect(redirectTo);
  }
);

Vaihe 11: Access Token -päivitys pitkäaikaisissa istunnoissa

Google-access tokenien elinikä on 1 tunti. Pitkäaikaisissa sovelluksissa, joissa tarvitaan jatkuvaa pääsyä Google API:hin, täytyy toteuttaa automaattinen token-päivitys refresh tokenin avulla. Tämä vaatii accessType: 'offline' -asetuksen Google-strategiassa.

// Päivitetty Google-strategia refresh token -tuella
passport.use(
  new GoogleStrategy(
    {
      clientID: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
      callbackURL: `${process.env.APP_URL}/auth/google/callback`,
      pkce: true,
      state: true,
      accessType: 'offline',   // Pyydä refresh token
      prompt: 'consent',       // Pakottaa refresh tokenin saamisen uudelleenkin
    },
    async (accessToken, refreshToken, params, profile, done) => {
      const expiresAt = Date.now() + (params.expires_in || 3600) * 1000;

      const user = {
        id: profile.id,
        displayName: profile.displayName,
        email: profile.emails?.[0]?.value,
        provider: 'google',
        // Tokenit tallennetaan tietokantaan (ei istuntoon)
        tokens: {
          accessToken,
          refreshToken,    // Tallenna salattuna tietokantaan
          expiresAt,
        },
        createdAt: new Date().toISOString(),
      };

      users.set(profile.id, user);
      return done(null, user);
    }
  )
);

// Apufunktio access tokenin päivittämiseen
async function refreshAccessToken(user) {
  const { createClient } = require('@googleapis/oauth2');
  const oauth2Client = createClient({
    clientId: process.env.GOOGLE_CLIENT_ID,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET,
  });

  oauth2Client.setCredentials({
    refresh_token: user.tokens.refreshToken,
  });

  const { credentials } = await oauth2Client.refreshAccessToken();
  user.tokens.accessToken = credentials.access_token;
  user.tokens.expiresAt = credentials.expiry_date;

  // Tallenna päivitetyt tokenit tietokantaan
  users.set(user.id, user);
  return user;
}

Refresh tokenit ovat erittäin arkaluonteisia: niiden avulla voidaan hankkia uusia access tokeneja ilman käyttäjän läsnäoloa. Tallenna refresh tokenit aina salattuna tietokantaan, esimerkiksi AES-256-GCM-salauksella. Älä koskaan tallenna niitä istuntoon, lokitiedostoihin tai ympäristömuuttujiin.

Vaihe 12: Tuotantovalmius ja tietoturvan vahvistaminen

Tuotantosovelluksessa on useita lisäasetuksia, jotka parantavat tietoturvaa ja suorituskykyä merkittävästi. Asenna Helmet.js HTTP-tietoturvaotsikoita varten ja lisää nopeusrajoitin autentikaatioreiteille brute force -hyökkäysten torjumiseksi.

npm install helmet express-rate-limit
// Lisää src/app.js -tiedostoon ennen reittejä
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');

// HTTP-tietoturva-otsikot Helmetin avulla
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "https://lh3.googleusercontent.com", "data:"],
    },
  },
  hsts: {
    maxAge: 31536000,   // 1 vuosi
    includeSubDomains: true,
    preload: true,
  },
}));

// Nopeusrajoitin autentikaatioreiteille
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,   // 15 minuutin ikkuna
  max: 20,                     // max 20 yritystä per IP
  message: {
    virhe: 'Liian monta kirjautumisyritystä. Yritä 15 minuutin kuluttua.',
  },
  standardHeaders: true,
  legacyHeaders: false,
});

app.use('/auth', authLimiter);

Yleisimmät sudenkuopat: 7 virhettä joita kehittäjät tekevät

OAuth2-toteutuksissa toistuvat samat virheet projekteista toiseen. Tässä seitsemän kriittisintä sudenkuoppaa ja niiden ratkaisut:

VirheTietoturvavaikutusRatkaisu
State-parametri puuttuuCSRF-hyökkäys mahdollinenAseta state: true tai käsittele manuaalisesti
Client Secret versionhallinnassaKoko OAuth-integraatio vaarantuu.env + .gitignore, tarkista git-historia
Istunto muistissa (MemoryStore)Istunnot häviävät palvelimen uudelleenkäynnistyksessäRedis tai muu pysyvä tallennusratkaisu
Callback URL ei täsmääGoogle hylkää pyynnönTarkista Cloud Consolesta, URL täsmälleen sama
req.logout() ilman callbackiaSovellus kaatuu Passport 0.6+ versioissaKäytä req.logout((err) => {...})
trust proxy puuttuuSecure-evästeet eivät toimi proxyjen takanaapp.set('trust proxy', 1)
Avoin uudelleenohjaus returnTo:ssaPhishing-hyökkäys kirjautumisen jälkeenHyväksy vain suhteelliset URL:t

Sudenkuoppa 1: Istunnon kiinnittymishyökkäys (Session Fixation). Jos istunto-ID ei uusiudu kirjautumisen yhteydessä, hyökkääjä voi ennalta asettaa uhrin istunto-ID:n ja kaapata session heti kirjautumisen jälkeen. Passport.js regeneroi istunnon automaattisesti, mutta varmista, ettet kutsu req.session.save() ennen kirjautumista tavalla joka estää tämän regeneroinnin.

Sudenkuoppa 2: PKCE puuttuu mobiili- tai SPA-sovelluksesta. Ilman PKCE:tä (RFC 7636) authorization code voidaan kaapata man-in-the-middle-hyökkäyksellä erityisesti mobiilisovelluksissa. IETF:n uusin BCP suosittelee PKCE:n käyttöä myös palvelinpuolen sovelluksissa. Passport.js 0.7.0 tukee PKCE:tä pkce: true -asetuksella.

Sudenkuoppa 3: Refresh token puuttuu. Ilman accessType: 'offline' ja prompt: 'consent' -asetuksia Google ei palauta refresh tokenia. Access token vanhenee tunnin kuluttua eikä sovellus pysty uusimaan sitä automaattisesti, mikä pakottaa käyttäjän kirjautumaan uudelleen tunnin välein.

Sudenkuoppa 4: Koko käyttäjäolio serialisoidaan istuntoon. Tallenna istuntoon vain käyttäjä-ID. Deserialiointi lataa tuoreen käyttäjätiedon tietokannasta joka pyynnöllä, jolloin käyttöoikeuksien muutokset astuvat voimaan välittömästi ilman uudelleenkirjautumista.

Sudenkuoppa 5: Turvaton uudelleenohjaus. Jos returnTo-parametri hyväksyy minkä tahansa URL:n, hyökkääjä voi ohjata käyttäjän phishing-sivulle kirjautumisen jälkeen. Tarkista aina, että uudelleenohjaus-URL on suhteellinen tai kuuluu sallittuihin domaineihin.

Vianmääritys: 8 yleisintä ongelmaa

Ongelma 1: “redirect_uri_mismatch” -virhe Googlelta. Google hylkää pyynnön, koska callback URL ei täsmää Cloud Consolessa rekisteröityyn URL:iin täsmälleen. Protokolla (http vs https), portti ja polku täytyy olla identtiset. Kehityksessä käytä http://localhost:3000, ei http://127.0.0.1:3000, vaikka molemmat viittaavat samaan osoitteeseen.

Ongelma 2: Istunto häviää joka pyynnöllä. Jos req.user on aina undefined vaikka kirjautuminen onnistui, ongelma on yleensä puuttuva passport.session()-väliohjelmisto tai virheellinen serialisointi. Tarkista, että passport.serializeUser ja passport.deserializeUser on määritelty ja että istunto on alustettu ennen Passport.js:ää.

Ongelma 3: “Cannot read properties of undefined” deserialioinnissa. Tämä tapahtuu kun käyttäjää ei löydy tietokannasta serialisoidulla ID:llä. Varmista, että kehityksessä käytettävä in-memory Map ei tyhjenee palvelimen uudelleenkäynnistysten välillä. Tuotannossa käytä aina pysyvää tietokantaa.

Ongelma 4: Redis-yhteys katkeaa. Virhe Error: Redis connection refused tarkoittaa, ettei Redis ole käynnissä tai yhteysosoite on väärä. Tarkista REDIS_URL-ympäristömuuttuja ja Redis-palvelimen tila: redis-cli ping palauttaa PONG jos yhteys toimii.

Ongelma 5: Evästeet eivät toimi tuotannossa. Jos secure: true on asetettu mutta sovellus toimii HTTP:n takana (esimerkiksi Nginx-proxyn takana), evästeet eivät siirry. Aseta app.set('trust proxy', 1) Express-sovellukseen, jotta se luottaa Nginx:n X-Forwarded-Proto: https -otsikkoon.

Ongelma 6: “req.logout() is not a function” tai kaatuu ilman callbackia. Passport.js 0.6:sta alkaen req.logout() vaatii pakollisen callback-funktion. Vanha muoto req.logout() ei toimi uusissa versioissa. Muuta kaikki kutsut muotoon req.logout((err) => { ... }).

Ongelma 7: Käyttäjä ohjataan kirjautumissivulle vaikka on kirjautunut. Jos req.isAuthenticated() palauttaa false vaikka istunto on olemassa, ongelma on usein eri istuntoavain kehityksessä ja tuotannossa tai muuttunut SESSION_SECRET. Kaikki olemassa olevat istunnot mitätöityvät kun salaisuus vaihtuu.

Ongelma 8: “invalid_grant” Google API -vastauksessa. Authorization code on kertakäyttöinen ja vanhenee noin 10 minuutissa. Tämä virhe tarkoittaa, että koodia on yritetty käyttää uudelleen tai se on vanhentunut. Tarkista, ettei callback-reitti aktivoidu kahdesti (esimerkiksi favicon.ico-pyyntö ei saa laukaista OAuth-callbackia).

OAuth 2.0 vs JWT vs perinteinen istuntokirjautuminen

Kehittäjät sekoittavat usein OAuth 2.0:n, JWT:n ja istuntopohjaisen todennuksen. Nämä eivät ole toisensa poissulkevia: OAuth 2.0 on valtuutuskehys, JWT on tokenmuoto ja istunnot ovat tallennusmekanismi. Niitä voidaan ja usein pitääkin käyttää yhdessä.

OminaisuusOAuth2 + Istunnot (Passport.js)OAuth2 + JWTSalasana + Istunnot
Palvelintilan tarveKyllä (Redis)Ei (stateless)Kyllä (Redis)
SkaalautuvuusRedis-klusterilla hyväErinomainenRedis-klusterilla hyvä
Välitön uloskirjautuminenKylläVaatii denylist-toteutuksenKyllä
MobiilisovelluksetRajoitettuErinomainenMahdollinen
Käyttäjän salasana sovelluksessaEiEi (OAuth2:lla)Kyllä (bcrypt/Argon2)
Toteutuksen monimutkaisuusKohtalainenKorkeaMatala

Verkkosovelluksille, joissa on perinteinen selain-palvelin-arkkitehtuuri, OAuth 2.0 yhdistettynä Redis-istuntoihin on yksinkertaisin ja turvallisin ratkaisu. REST API -palveluille, joita kuluttavat mobiilisovellukset tai muut mikropalvelut, JWT-tokenit OAuth 2.0:n kanssa ovat parempi valinta, koska ne mahdollistavat stateless-arkkitehtuurin ilman istuntotallennuksen monimutkaisuutta.

Edistynyt: PostgreSQL-tietokantaintegraatio ja tilin yhdistäminen

Tietokantaskeema useille OAuth2-tarjoajille

Tuotantosovelluksessa käyttäjätiedot tallennetaan relaatiotietokantaan. Erota käyttäjätaulu ja OAuth-tilitaulu toisistaan, jolloin sama käyttäjä voi kirjautua useilla eri tarjoajilla samaan tiliin:

-- PostgreSQL-skeema OAuth2-käyttäjille
CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email VARCHAR(255) UNIQUE NOT NULL,
  display_name VARCHAR(255),
  photo_url TEXT,
  created_at TIMESTAMP DEFAULT NOW(),
  last_login TIMESTAMP
);

CREATE TABLE oauth_accounts (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES users(id) ON DELETE CASCADE,
  provider VARCHAR(50) NOT NULL,           -- 'google', 'github', 'microsoft'
  provider_id VARCHAR(255) NOT NULL,
  access_token TEXT,
  refresh_token TEXT,                       -- Tallenna AES-256-GCM-salattuna
  token_expires_at TIMESTAMP,
  created_at TIMESTAMP DEFAULT NOW(),
  UNIQUE(provider, provider_id)
);

-- Indeksi nopeaan hakuun kirjautumisen yhteydessä
CREATE INDEX idx_oauth_provider ON oauth_accounts(provider, provider_id);

Automaattinen tilin yhdistäminen sähköpostiosoitteen perusteella

Käyttäjällä voi olla sama sähköpostiosoite sekä Google- että GitHub-tilillä. Ilman tilin yhdistämistä sama henkilö saa kaksi erillistä käyttäjätiliä, mikä johtaa epäjohdonmukaiseen kokemukseen. Toteuta tilin yhdistäminen OAuth2-callback-funktiossa:

// Käytä tätä funktiota GoogleStrategy- ja GitHubStrategy-callbackeissa
async function findOrCreateUser(db, profile, provider) {
  const email = profile.emails?.[0]?.value;

  // 1. Etsi ensin OAuth-tilin perusteella (nopein polku)
  const oauthResult = await db.query(
    `SELECT u.* FROM users u
     JOIN oauth_accounts oa ON u.id = oa.user_id
     WHERE oa.provider = $1 AND oa.provider_id = $2`,
    [provider, profile.id]
  );

  if (oauthResult.rows.length > 0) {
    // Päivitä kirjautumisaika
    await db.query('UPDATE users SET last_login = NOW() WHERE id = $1', [oauthResult.rows[0].id]);
    return oauthResult.rows[0];
  }

  // 2. Yhdistä olemassa olevaan tiliin sähköpostin perusteella
  let userId;
  if (email) {
    const emailResult = await db.query('SELECT id FROM users WHERE email = $1', [email]);
    userId = emailResult.rows[0]?.id;
  }

  // 3. Luo uusi käyttäjä jos ei löydy
  if (!userId) {
    const newUser = await db.query(
      'INSERT INTO users (email, display_name, photo_url) VALUES ($1, $2, $3) RETURNING id',
      [email, profile.displayName, profile.photos?.[0]?.value]
    );
    userId = newUser.rows[0].id;
  }

  // 4. Tallenna OAuth-tili
  await db.query(
    'INSERT INTO oauth_accounts (user_id, provider, provider_id) VALUES ($1, $2, $3)',
    [userId, provider, profile.id]
  );

  const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
  return user.rows[0];
}

Tietoturvatarkistuslista ennen tuotantoonsiirtoa

Käy läpi tämä tarkistuslista ennen sovelluksen julkaisua. Jokainen kohta on kriittinen OAuth2-toteutuksen tietoturvalle:

  • PKCE aktivoitu: pkce: true Passport.js-strategiassa
  • State-parametri käytössä: state: true CSRF-suojaksi
  • HTTPS pakollinen: secure: true evästeille, HTTPS callback-URL
  • Client Secret ympäristömuuttujassa: ei koodissa, ei git-historiassa
  • Redis-istuntotallennus: ei MemoryStore tuotannossa
  • Istunnon TTL asetettu: vanhenemisaika sekä Redisiin (ttl) että evästeeseen (maxAge)
  • Evästeen nimi muutettu: ei oletusarvoista connect.sid
  • trust proxy asetettu: load balancerin tai Nginx:n takana
  • Nopeusrajoitin autentikointireiteillä: suojaa brute force -hyökkäyksiltä
  • Helmet.js käytössä: HTTP-tietoturva-otsikot asetettu
  • Uudelleenohjauksen validointi: vain suhteelliset URL:t hyväksytään returnTo:ssa
  • Refresh tokenit salattu tietokantaan: ei selkotekstinä, ei istuntoon

Usein kysytyt kysymykset

Onko Passport.js edelleen paras valinta Node.js OAuth2 -toteutukseen vuonna 2026?
Passport.js on edelleen suosituin ratkaisu yli 1,4 miljoonan viikoittaisen npm-latauksen perusteella. Vaihtoehdot kuten openid-client (OpenID Connect -spesifinen kirjasto) tai Auth.js (NextAuth.js:n seuraaja) ovat hyviä vaihtoehtoja modernimpiin projekteihin. Passport.js sopii parhaiten Express-pohjaisiin sovelluksiin, joissa tarvitaan useita todennusstrategioita rinnakkain.

Mikä on ero OAuth 2.0:n ja OpenID Connectin välillä?
OAuth 2.0 on valtuutuskehys, joka antaa sovellukselle pääsyn resursseihin käyttäjän puolesta, mutta se ei itsessään todenna käyttäjää. OpenID Connect rakentuu OAuth 2.0:n päälle ja lisää todennuksen ID-tokenin muodossa. Käytännössä Google, GitHub ja Microsoft tukevat OIDC:tä, joten saat sekä valtuutuksen että identiteetin samassa virrassa pyytämällä openid-scopen.

Pitääkö PKCE:tä käyttää palvelinpuolen Node.js-sovelluksissa?
PKCE on alun perin suunniteltu julkisille asiakkaille, mutta IETF:n uusin BCP (OAuth 2.0 Security Best Current Practice) suosittelee PKCE:n käyttöä myös palvelinpuolen sovelluksissa. Passport.js 0.7.0 tukee PKCE:tä pkce: true -asetuksella. Suositus on ottaa se käyttöön kaikissa uusissa projekteissa.

Kuinka tallentaa access ja refresh tokenit turvallisesti?
Tallenna refresh tokenit aina salattuna tietokantaan, esimerkiksi AES-256-GCM-salauksella, ennen tallentamista. Access tokeneita ei yleensä tarvitse tallentaa pysyvästi, koska ne haetaan uudelleen refresh tokenin avulla tarvittaessa. Älä koskaan tallenna tokeneita istuntoon, lokitiedostoihin tai ympäristömuuttujiin.

Miten toteuttaa uloskirjautuminen kaikista laitteista samanaikaisesti?
Poista kaikki käyttäjän istunnot Redisistä käyttämällä SCAN-komentoa. Tehokkaampi tapa on käyttää käyttäjäkohtaisia Redis-avaimia: tallenna istunto-ID:t käyttäjä-ID:n alle (user:sessions:{userId}) ja poista ne kaikki kerralla uloskirjautumisen yhteydessä.