@aplinkosministerija/viisp-v3-oidc
v0.1.2
Published
Framework-agnostic TypeScript client for VIISP V3 (eVartai) OIDC citizen authentication. Handles the authorize leg, signed state cookie, private_key_jwt token exchange, id_token verification and identity mapping — leaving user provisioning and session iss
Maintainers
Readme
@aplinkosministerija/viisp-v3-oidc
Framework-agnostic TypeScript client for VIISP V3 (eVartai) OIDC citizen authentication — the Lithuanian national identity provider operated by the Information Society Development Committee.
Extracted from biip-alis-api so any BIIP application can add eVartai login without re-implementing the protocol.
What it does, and what it deliberately does not
It does the whole VIISP-facing protocol:
- builds the
authorizeURL withstate+nonce - seals the pending login into a signed, HttpOnly state cookie
- verifies your signing key actually pairs with the certificate registered at VIISP
- fetches and caches the discovery document + JWKS, and checks the issuer
- exchanges the authorization code using
private_key_jwt(RS256client_assertion) - verifies the
id_token— signature, issuer, audience,nonce,azp - maps VIISP claims onto a flat identity object
It does not know about your users, sessions, database, or HTTP framework. It hands you a verified identity; provisioning the account and issuing your own session stays in your application, where it belongs.
Install
npm install @aplinkosministerija/viisp-v3-oidcRequires Node 18+ (uses the global fetch).
Usage
Configure once
import { buildV3Config } from '@aplinkosministerija/viisp-v3-oidc';
const cfg = buildV3Config({
env: 'STAGE', // DEV | TEST | STAGE | PROD
pid: process.env.VIISP_V3_PID!, // your VIISP service PID = OAuth2 client_id
allowedHosts: ['https://myapp.biip.lt'],
stateCookieSecret: process.env.JWT_SECRET!,
cookieSecure: process.env.NODE_ENV === 'production',
keyPath: './keys/viisp-v3-private.pem',
certPath: './keys/viisp-v3-public.crt',
});configFromEnv({ stateCookieSecret }) is available if you would rather adopt
the reference VIISP_V3_* variable names (see Configuration).
Leg 1 — start the login
import { beginLogin } from '@aplinkosministerija/viisp-v3-oidc';
const { authorizeUrl, setCookie } = beginLogin(cfg, { host: feOrigin });
res.setHeader('Set-Cookie', setCookie);
res.json({ url: authorizeUrl }); // FE navigates the browser hereLeg 2 — handle the callback
import { completeLogin, peekPendingLogin, V3Error } from '@aplinkosministerija/viisp-v3-oidc';
const stateCookie = parseCookies(req.headers.cookie)[cfg.stateCookieName ?? 'viisp_v3_state'];
try {
const { identity, clearCookie } = await completeLogin(cfg, {
code: req.query.code,
state: req.query.state,
stateCookie,
logger: myLogger,
});
res.setHeader('Set-Cookie', [clearCookie, mySessionCookie(identity)]);
// identity.personalCode is guaranteed present here — provision your user now.
} catch (err) {
const feHost = peekPendingLogin(cfg, stateCookie)?.host ?? cfg.allowedHosts[0];
if (err instanceof V3Error) { /* map to your own status + localised message */ }
res.redirect(`${feHost}/login?error=login_failed`);
}The flow
FE ──POST /sign──► beginLogin() → authorizeUrl + state cookie
browser ────────────► VIISP authorize page
VIISP ──GET callback?code&state──► completeLogin()
├─ verify state cookie
├─ key/cert preflight
├─ discovery + issuer check
├─ token exchange (private_key_jwt)
├─ verify id_token (nonce, azp, …)
└─ map claims → EvData
your app ──────────► create/find user, issue session, redirect to FEConfiguration
| Option | Required | Notes |
|---|---|---|
| env | yes | DEV / TEST / STAGE / PROD — selects the ap*.epaslaugos.lt host |
| pid | yes | VIISP service PID; used as client_id and expected aud |
| allowedHosts | yes | FE origins permitted to start a login |
| stateCookieSecret | yes | HMAC secret for the state cookie |
| cookieSecure | yes | emit Secure; false only for plain-HTTP local dev |
| privateKeyPem / keyPath | one of | PKCS#8 key signing client_assertion |
| certBytes / certPath | one of | registered client certificate (DER .cer or PEM) |
| redirectUri | no | full override; otherwise derived from the FE origin |
| redirectPath | no | default /api/auth/evartai/v3/callback |
| offlineAccess | no | request offline_access for a refresh token |
| stateCookieName | no | default viisp_v3_state |
| authorizeUrl / authorizeBase | no | when AP fronts authorize elsewhere |
configFromEnv() reads VIISP_V3_ENV, VIISP_V3_PID, VIISP_ALLOWED_HOSTS,
VIISP_V3_PRIVATE_KEY, VIISP_V3_KEY_PATH, VIISP_V3_CERT_PATH,
VIISP_V3_REDIRECT_URI, VIISP_V3_OFFLINE_ACCESS, VIISP_V3_AUTHORIZE_URL,
VIISP_V3_AUTHORIZE_BASE. Anything you pass explicitly wins.
An omitted VIISP_V3_ENV defaults to STAGE; an unrecognised non-empty value
throws instead of silently routing authentication to the wrong environment.
Errors
All extend V3Error, so catch (e) { if (e instanceof V3Error) … } works.
| Error | Meaning | Suggested status |
|---|---|---|
| V3HostNotAllowedError | origin not allow-listed — possible callback hijack | 400 |
| V3StateMismatchError | state cookie missing/expired/tampered, or state differs | 401 |
| V3ConfigError | cert unreadable, or key/cert do not pair | 500 |
| V3DiscoveryError | discovery unreachable or issuer mismatch | 502 |
| V3TokenExchangeError | token endpoint rejected the exchange | 502 |
| V3IdTokenError | signature / issuer / audience / nonce / azp failure | 401 |
| V3IdentityIncompleteError | verified, but no personal code present | 401 |
Security notes
redirect_uricomes from the signed state cookie, never re-derived from the callback request — the two legs must match byte-for-byte, and the callback is attacker-influenced.SameSite=Laxon the state cookie is required, not an oversight: the VIISP callback is a top-level cross-site GET, andStrictwould withhold the cookie and break every login.- State cookies fail closed. Missing field, wrong algorithm, bad signature,
expiry →
null, never a partially-trusted object. - Only one login can be pending per cookie name. Starting another login in
the same browser overwrites the earlier state cookie; use distinct
stateCookieNamevalues for independently configured concurrent flows. - The key/cert preflight refuses the exchange on mismatch. Otherwise VIISP answers with an opaque "Could not validate signature" that gives no hint the cause is local.
- Personal codes are never logged. When
userDataandsubdisagree the library warns that they diverged, without emitting either value. - The library never logs the authorization code, the
client_assertion, or the private key.
The sub fallback
VIISP normally returns the personal code in
userData.authenticationAttribute. On TEST (VSID000000000115-demo) it has
been observed to arrive only in the top-level sub claim, formatted
<kodas>:<reikšmė>. The mapper prefers the explicit attribute and falls back to
sub, accepting only recognised personal-code prefixes
(lt-personal-code, iltu-personal-code, eidas-eid) so a company-only sub
can never masquerade as a personal identifier.
Development
npm install
npm test
npm run buildLicense
MIT
