Verifying Tokens
The acr check no OIDC library does for you, and the whole check where there is no library at all.
Your application exchanges the authorization code with whatever OIDC library it already runs. This page is about the one check those libraries cannot do for you, and about the case where there is no library at all.
The check no OIDC library does¶
Conformant libraries validate signature, issuer, audience, expiry and nonce.
None checks acr, because none knows your policy — and a token can verify
perfectly and still say the person authenticated in a way this particular
action should not accept.
import { verifyClaims, ACR_FACE, AcrNotSatisfied } from "@guacamole_hbai/auth";
try {
verifyClaims(claims, { requireAcr: [ACR_FACE], maxAge: 300 });
} catch (error) {
if (error instanceof AcrNotSatisfied) return stepUp(); // not signOut()
throw error;
}
AcrNotSatisfied is a different class from a bad token on purpose. The two
mean opposite things to a caller: a bad token is "this is not yours to
trust", while an unmet acr is "this person authenticated, and not strongly
enough for this" — which is a step-up, not a sign-out. Catching both the same
way signs out people who were only ever going to be asked for one more factor.
maxAge is checked against auth_time, never iat. A token minted by a
refresh is newly issued and says nothing about when a face was last presented.
requireAcr is not an assurance level. A biometric alone is not an
authenticator under SP 800-63B, and a face-only ceremony reports an acr named
to make that impossible to miss in a log. See
acr and amr for the values.
Making the check impossible to forget¶
verifyClaims returns VerifiedClaims — the same claims, frozen, carrying a
brand the type system can see. Type your session code to take it:
function createSession(claims: VerifiedClaims) { … }
Passing an unverified claim set is now a compile error rather than a code review someone has to remember to do.
The brand is non-enumerable, so it does not survive JSON.stringify. That is
deliberate: it means "verified in this process", never "verified at some
point", and a claim set that came back through a session cookie arrives
unbranded. assertVerified(claims) is the tripwire for that boundary.
Where there is no OIDC library¶
Some integrations reach the callback with a fetch and a
JSON.parse(atob(token.split(".")[1])). For those, gid.tokens.verify is the
whole check — signature against the provider's JWKS, issuer, audience, expiry,
nonce, then acr and freshness — and it returns the same VerifiedClaims:
const claims = await gid.tokens.verify(idToken, {
nonce: session.nonce, // required, and that is the point
requireAcr: [ACR_FACE],
});
nonce is not optional. A verifier that silently skips nonce binding
accepts an ID token obtained in somebody else's authorize flow and replayed
into this one — with a perfect signature, the right issuer and the right
audience, so nothing about the failure looks like a failure. If you have no
nonce to check, you are not in a position to verify an ID token: the code flow
gave you one, and your own OIDC state is where it went.
Two refusals worth knowing about, because JSON.parse(atob(…)) accepts both:
algis pinned to RS256 rather than read from the header. Reading it is howalg: noneworks.- A security event token replayed at the token verifier is refused on
typ. The same key signs both, so without that check it satisfies every other condition on the list.
If you already run a conformant library, keep it and use verifyClaims on what
it hands you. tokens.verify is for the case where the alternative was no
verification at all.