Skip to content
academia.sh

Lesson 09 / 23

Enterprise Federation

Checking an identity carried by an assertion through signature, issuer, audience, and duration window; preventing reuse; mapping externally supplied attributes to a local role with an explicit table; and the limit of logout.

Contents

The previous lesson added the identity layer on top of the authorization delegation protocol: the application learned who the user was from a signed document. The party that issued the document and the party that consumed it were parts of the same organization.

In enterprise federation, this assumption is dropped. The user authenticates their identity at their own organization; the library service accepts the user by trusting the document that organization issued. The library never sees the password and never keeps a user list. The name of this arrangement is single sign-on, and its carrier is the assertion.

The Direction and Limit of Trust

Federation has two parties: the identity provider that authenticates the identity, and the service provider that manages access to the resource. Trust is one-directional and limited: the service provider trusts the identity provider’s claim of “this person is who they are”; it makes the decision of “this person can do this” itself.

This distinction is the cross-organization form of the distinction in the course’s first lesson. Identity comes from outside, authorization stays inside. If an externally supplied attribute is translated directly into authorization, the neighboring organization’s group management ends up determining our authorization system.

The account below runs the issuance, verification, and mapping of an assertion to a local role end to end.

cat > federation.mjs <<'EOF'
// federation.mjs — issuing, verifying, and mapping an assertion to a local role
import { createHmac, timingSafeEqual, randomBytes } from "node:crypto";

const SECRET = Buffer.from("org-signing-key-example");
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
const sign = (body) => createHmac("sha256", SECRET).update(body).digest("base64url");

// The identity-providing organization issues an assertion.
function issueAssertion({ subject, emailVerified, groups, audience, seconds, time }) {
  const body = b64({
    iss: "https://auth.north-university.test",
    sub: subject, aud: audience,
    iat: time, exp: time + seconds,
    jti: randomBytes(6).toString("hex"),
    email_verified: emailVerified, groups,
  });
  return body + "." + sign(body);
}

// The service-providing organization verifies the assertion. Each step is a separate decision.
const USED = new Set();
function verify(assertion, { audience, now, issuer }) {
  const [body, signature] = assertion.split(".");
  const expected = sign(body);
  if (signature.length !== expected.length ||
      !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) return "reject: signature";
  const claims = JSON.parse(Buffer.from(body, "base64url"));
  if (claims.iss !== issuer) return "reject: issuer";
  if (claims.aud !== audience) return "reject: audience";
  if (now >= claims.exp) return "reject: expired";
  if (now < claims.iat) return "reject: future-dated";
  if (USED.has(claims.jti)) return "reject: reuse";
  USED.add(claims.jti);
  return "accept";
}

const TIME = 1_770_000_000;                       // fixed time: so the output is reproducible
const AUDIENCE = "https://library.north-university.test";
const ISSUER = "https://auth.north-university.test";
const base = { subject: "std-4417", emailVerified: true, groups: ["student", "science-faculty"],
                audience: AUDIENCE, seconds: 300, time: TIME };

const trials = [
  ["valid assertion", issueAssertion(base), { audience: AUDIENCE, now: TIME + 10, issuer: ISSUER }],
  ["written to another org", issueAssertion({ ...base, audience: "https://other.test" }), { audience: AUDIENCE, now: TIME + 10, issuer: ISSUER }],
  ["expired", issueAssertion({ ...base, seconds: 5 }), { audience: AUDIENCE, now: TIME + 60, issuer: ISSUER }],
  ["signature broken", issueAssertion(base).slice(0, -1) + "x", { audience: AUDIENCE, now: TIME + 10, issuer: ISSUER }],
  ["from another issuer", issueAssertion(base), { audience: AUDIENCE, now: TIME + 10, issuer: "https://fake.test" }],
];
console.log("trial                      result");
for (const [name, assertion, condition] of trials) console.log(`  ${name.padEnd(24)} ${verify(assertion, condition)}`);

// If the same assertion is presented twice
const again = issueAssertion(base);
console.log(`  ${"first presentation".padEnd(24)} ${verify(again, { audience: AUDIENCE, now: TIME + 10, issuer: ISSUER })}`);
console.log(`  ${"same assertion again".padEnd(24)} ${verify(again, { audience: AUDIENCE, now: TIME + 20, issuer: ISSUER })}`);

// Externally supplied attributes are mapped to a local role; the mapping table must be explicit.
const MAPPING = [
  { condition: (c) => c.groups.includes("library-staff"), role: "clerk" },
  { condition: (c) => c.groups.includes("student") && c.email_verified, role: "member" },
  { condition: (c) => c.groups.includes("faculty"), role: "member" },
];
function localRole(assertion) {
  const c = JSON.parse(Buffer.from(assertion.split(".")[0], "base64url"));
  const matched = MAPPING.find((m) => m.condition(c));
  return { subject: c.sub, groups: c.groups.join(","), role: matched ? matched.role : "no role" };
}
console.log("\nexternal subject           groups                      local role");
for (const groups of [["student", "science-faculty"], ["library-staff"], ["guest"]]) {
  const result = localRole(issueAssertion({ ...base, groups }));
  console.log(`  ${result.subject.padEnd(24)} ${result.groups.padEnd(27)} ${result.role}`);
}
const unverified = localRole(issueAssertion({ ...base, emailVerified: false, groups: ["student"] }));
console.log(`  ${unverified.subject.padEnd(24)} ${"student (email not verified)".padEnd(27)} ${unverified.role}`);
EOF
node federation.mjs
trial                      result
  valid assertion          accept
  written to another org   reject: audience
  expired                  reject: expired
  signature broken         reject: signature
  from another issuer      reject: issuer
  first presentation       accept
  same assertion again     reject: reuse

external subject           groups                      local role
  std-4417                 student,science-faculty     member
  std-4417                 library-staff               clerk
  std-4417                 guest                       no role
  std-4417                 student (email not verified) no role

Verification Is Not One Step

The output’s first section shows five separate rejections, and each comes from a separate check. The signature says the document was not altered in transit — but it is not sufficient on its own. The issuer check verifies that the document came from the expected organization; if this check is skipped, any organization capable of issuing a validly signed document can identify our users.

The audience check is the one most often skipped. The identity provider issues documents for more than one service for the same user; if a document issued for one service is accepted at another, a weakly protected service becomes a gateway into a strongly protected one. The document’s audience states who is meant to accept it.

The duration window is two-sided: the past side checks the document’s lifetime, the future side checks clock skew. Keeping the window narrow shortens the usable time if the document is intercepted.

The Same Document Cannot Be Used Twice

The second section’s two lines show the reuse defense. The document carries a unique identifier, and this identifier is recorded when the document is consumed; the same document is rejected when presented a second time.

The record’s lifetime is as long as the document’s lifetime: storing the identifier of an expired document is unnecessary, because the duration check already rejects it. This keeps the record from growing without bound — the reuse list is a record that looks back only as far as the validity window.

The Attribute-to-Role Mapping Must Be Explicit

The third section runs the mapping table. Externally supplied group names are not used directly as a role; they are translated according to a local table. A group with no match gives a “no role” result, and the user cannot access the resource.

The last line shows an additional condition: being in the student group is not enough — the email must also be verified. Not every attribute the identity provider sends carries the same reliability; which attribute to trust under which condition is the service provider’s decision.

The practical consequence of the table being explicit is this: when the neighboring organization opens a new group, nothing changes in our system. Authorization only expands when a row is added to the mapping table, and that row passes through review.

The Limit of Logout

In federation, logout is not one-sided. When the user logs out of the library service, their session at the identity provider persists; the redirect on the next request silently issues a new document, and the user appears never to have logged out.

This has two counterparts. A local logout ends only the session at the service provider and does not satisfy a “sign in with another account” request. A central logout also ends the session at the identity provider and affects every service tied to that provider. Shared machines require the second one; the user must be told plainly which one was done.

Summary

  • In federation, identity comes from outside and authorization stays inside: the assertion states who the person is, and the service provider determines what they can do.
  • Verification is not one step; signature, issuer, audience, and duration window are each checked separately — in the model, each of these checks produced a separate rejection.
  • The document is consumed once with its unique identifier; the reuse list looks back only as far as the document’s validity window.
  • Externally supplied attributes are translated into a local role with an explicit table; an unmatched group gets no authorization, and the neighboring organization opening a group does not change our authorizations.
  • Local logout and central logout are different things; which one was done must be communicated to the user.

Next Step

In every flow up to this point, there was a human on the other end: someone entering a password, reading the consent screen, providing the second factor. But some of the callers to the library service are not human — a batch job running overnight, the neighboring organization’s catalog syncer, a dashboard. None of these has a password or a consent screen. The next lesson addresses the credential used for these callers: how it is issued, where it is stored, and how it is rotated.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close