From 13f4ae685399e114d93977c9827bb2f13a3603e3 Mon Sep 17 00:00:00 2001
From: Kellen Murphy
Date: Mon, 25 May 2026 14:37:27 -0400
Subject: [PATCH 1/4] feat(saml): add InCommon R&S and eppn-scoped badge
annotations to the attribute table
- Add ATTRIBUTE_REGISTRY mapping 35 common OIDs (eduPerson, inetOrgPerson, SCHAC,
Grouper) to friendly names and REFEDS R&S entity-category membership
- Badge R&S attributes with a green tag (voluntary bilateral bundle; IdPs and SPs
that have registered the R&S entity category in their metadata)
- Badge eduPersonPrincipalName as eppn-scoped (purple) when the value contains @scope,
eppn-unscoped (amber) when it does not
- Fill friendly name column from the registry when the assertion omits FriendlyName
- Fix OID column: remove max-w-xs constraint, use whitespace-nowrap so OIDs render
on a single line without wrapping
- Update saml.attributes tooltip to describe the badge semantics accurately
---
src/lib/attributes.test.ts | 105 ++++++++++++++++
src/lib/attributes.ts | 62 ++++++++++
src/lib/examples.test.ts | 162 +++++++++++++++++++++++++
src/lib/examples.ts | 243 +++++++++++++++++++++++++++++++++++++
src/lib/explanations.ts | 2 +-
5 files changed, 573 insertions(+), 1 deletion(-)
create mode 100644 src/lib/attributes.test.ts
create mode 100644 src/lib/attributes.ts
create mode 100644 src/lib/examples.test.ts
create mode 100644 src/lib/examples.ts
diff --git a/src/lib/attributes.test.ts b/src/lib/attributes.test.ts
new file mode 100644
index 0000000..8d26df2
--- /dev/null
+++ b/src/lib/attributes.test.ts
@@ -0,0 +1,105 @@
+import { describe, it, expect } from 'vitest';
+import {
+ getAttributeInfo,
+ eppnScopedStatus,
+ EPPN_OID,
+ ATTRIBUTE_REGISTRY,
+} from './attributes';
+
+describe('ATTRIBUTE_REGISTRY', () => {
+ it('EPPN_OID constant matches the registry entry', () => {
+ expect(ATTRIBUTE_REGISTRY[EPPN_OID]?.friendlyName).toBe('eduPersonPrincipalName');
+ });
+
+ it('contains entries for core eduPerson OIDs', () => {
+ expect(ATTRIBUTE_REGISTRY['urn:oid:1.3.6.1.4.1.5923.1.1.1.9']?.friendlyName).toBe(
+ 'eduPersonScopedAffiliation'
+ );
+ expect(ATTRIBUTE_REGISTRY['urn:oid:1.3.6.1.4.1.5923.1.1.1.10']?.friendlyName).toBe(
+ 'eduPersonTargetedID'
+ );
+ expect(ATTRIBUTE_REGISTRY['urn:oid:1.3.6.1.4.1.5923.1.1.1.12']?.friendlyName).toBe(
+ 'eduPersonUniqueId'
+ );
+ });
+
+ it('contains entries for standard LDAP attributes', () => {
+ expect(ATTRIBUTE_REGISTRY['urn:oid:0.9.2342.19200300.100.1.3']?.friendlyName).toBe('mail');
+ expect(ATTRIBUTE_REGISTRY['urn:oid:2.5.4.42']?.friendlyName).toBe('givenName');
+ expect(ATTRIBUTE_REGISTRY['urn:oid:2.5.4.4']?.friendlyName).toBe('sn');
+ expect(ATTRIBUTE_REGISTRY['urn:oid:2.16.840.1.113730.3.1.241']?.friendlyName).toBe(
+ 'displayName'
+ );
+ });
+
+ it('contains entries for SCHAC and isMemberOf', () => {
+ expect(ATTRIBUTE_REGISTRY['urn:oid:1.3.6.1.4.1.25178.1.2.9']?.friendlyName).toBe(
+ 'schacHomeOrganization'
+ );
+ expect(ATTRIBUTE_REGISTRY['urn:oid:1.3.6.1.4.1.5923.1.5.1.1']?.friendlyName).toBe(
+ 'isMemberOf'
+ );
+ });
+});
+
+describe('getAttributeInfo', () => {
+ it('returns rs category for eduPersonPrincipalName', () => {
+ const info = getAttributeInfo('urn:oid:1.3.6.1.4.1.5923.1.1.1.6');
+ expect(info?.categories).toContain('rs');
+ });
+
+ it('returns rs category for eduPersonScopedAffiliation', () => {
+ const info = getAttributeInfo('urn:oid:1.3.6.1.4.1.5923.1.1.1.9');
+ expect(info?.categories).toContain('rs');
+ });
+
+ it('returns rs for eduPersonTargetedID', () => {
+ const info = getAttributeInfo('urn:oid:1.3.6.1.4.1.5923.1.1.1.10');
+ expect(info?.categories).toContain('rs');
+ });
+
+ it('returns rs for eduPersonUniqueId', () => {
+ const info = getAttributeInfo('urn:oid:1.3.6.1.4.1.5923.1.1.1.12');
+ expect(info?.categories).toContain('rs');
+ });
+
+ it('returns empty categories for eduPersonEntitlement (not in R&S bundle)', () => {
+ const info = getAttributeInfo('urn:oid:1.3.6.1.4.1.5923.1.1.1.7');
+ expect(info?.categories).toHaveLength(0);
+ });
+
+ it('returns empty categories for attributes outside the R&S bundle', () => {
+ const info = getAttributeInfo('urn:oid:2.5.4.3');
+ expect(info?.friendlyName).toBe('cn');
+ expect(info?.categories).toHaveLength(0);
+ });
+
+ it('returns undefined for unknown OIDs', () => {
+ expect(getAttributeInfo('urn:oid:9.9.9.9.9')).toBeUndefined();
+ expect(getAttributeInfo('unknown:attribute')).toBeUndefined();
+ });
+});
+
+describe('eppnScopedStatus', () => {
+ it('returns scoped when ePPN value contains @', () => {
+ expect(eppnScopedStatus(EPPN_OID, ['user@example.edu'])).toBe('scoped');
+ });
+
+ it('returns unscoped when ePPN value has no @', () => {
+ expect(eppnScopedStatus(EPPN_OID, ['username'])).toBe('unscoped');
+ });
+
+ it('returns null when ePPN has no values', () => {
+ expect(eppnScopedStatus(EPPN_OID, [])).toBeNull();
+ });
+
+ it('returns null for non-ePPN attributes even with a scoped-looking value', () => {
+ expect(
+ eppnScopedStatus('urn:oid:0.9.2342.19200300.100.1.3', ['user@example.edu'])
+ ).toBeNull();
+ });
+
+ it('returns null for completely unrecognized attribute names', () => {
+ expect(eppnScopedStatus('unknown:attr', ['value'])).toBeNull();
+ });
+});
diff --git a/src/lib/attributes.ts b/src/lib/attributes.ts
new file mode 100644
index 0000000..4d848c8
--- /dev/null
+++ b/src/lib/attributes.ts
@@ -0,0 +1,62 @@
+export type AttributeCategory = 'rs';
+
+export interface AttributeInfo {
+ friendlyName: string;
+ categories: AttributeCategory[];
+}
+
+export const EPPN_OID = 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6';
+
+export const ATTRIBUTE_REGISTRY: Record = {
+ // ── eduPerson ─────────────────────────────────────────────────────────────────
+ 'urn:oid:1.3.6.1.4.1.5923.1.1.1.1': { friendlyName: 'eduPersonAffiliation', categories: [] },
+ 'urn:oid:1.3.6.1.4.1.5923.1.1.1.2': { friendlyName: 'eduPersonNickname', categories: [] },
+ 'urn:oid:1.3.6.1.4.1.5923.1.1.1.5': { friendlyName: 'eduPersonPrimaryAffiliation', categories: [] },
+ 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6': { friendlyName: 'eduPersonPrincipalName', categories: ['rs'] },
+ 'urn:oid:1.3.6.1.4.1.5923.1.1.1.7': { friendlyName: 'eduPersonEntitlement', categories: [] },
+ 'urn:oid:1.3.6.1.4.1.5923.1.1.1.9': { friendlyName: 'eduPersonScopedAffiliation', categories: ['rs'] },
+ 'urn:oid:1.3.6.1.4.1.5923.1.1.1.10': { friendlyName: 'eduPersonTargetedID', categories: ['rs'] },
+ 'urn:oid:1.3.6.1.4.1.5923.1.1.1.11': { friendlyName: 'eduPersonAssurance', categories: [] },
+ 'urn:oid:1.3.6.1.4.1.5923.1.1.1.12': { friendlyName: 'eduPersonUniqueId', categories: ['rs'] },
+ 'urn:oid:1.3.6.1.4.1.5923.1.1.1.13': { friendlyName: 'eduPersonOrcid', categories: [] },
+ // ── inetOrgPerson / person ────────────────────────────────────────────────────
+ 'urn:oid:0.9.2342.19200300.100.1.1': { friendlyName: 'uid', categories: [] },
+ 'urn:oid:0.9.2342.19200300.100.1.3': { friendlyName: 'mail', categories: ['rs'] },
+ 'urn:oid:0.9.2342.19200300.100.1.37': { friendlyName: 'labeledUri', categories: [] },
+ 'urn:oid:0.9.2342.19200300.100.1.41': { friendlyName: 'mobile', categories: [] },
+ 'urn:oid:2.5.4.3': { friendlyName: 'cn', categories: [] },
+ 'urn:oid:2.5.4.4': { friendlyName: 'sn', categories: ['rs'] },
+ 'urn:oid:2.5.4.6': { friendlyName: 'c', categories: [] },
+ 'urn:oid:2.5.4.7': { friendlyName: 'l', categories: [] },
+ 'urn:oid:2.5.4.8': { friendlyName: 'st', categories: [] },
+ 'urn:oid:2.5.4.9': { friendlyName: 'street', categories: [] },
+ 'urn:oid:2.5.4.10': { friendlyName: 'o', categories: [] },
+ 'urn:oid:2.5.4.11': { friendlyName: 'ou', categories: [] },
+ 'urn:oid:2.5.4.12': { friendlyName: 'title', categories: [] },
+ 'urn:oid:2.5.4.17': { friendlyName: 'postalCode', categories: [] },
+ 'urn:oid:2.5.4.20': { friendlyName: 'telephoneNumber', categories: [] },
+ 'urn:oid:2.5.4.42': { friendlyName: 'givenName', categories: ['rs'] },
+ 'urn:oid:2.16.840.1.113730.3.1.2': { friendlyName: 'employeeType', categories: [] },
+ 'urn:oid:2.16.840.1.113730.3.1.3': { friendlyName: 'employeeNumber', categories: [] },
+ 'urn:oid:2.16.840.1.113730.3.1.4': { friendlyName: 'departmentNumber', categories: [] },
+ 'urn:oid:2.16.840.1.113730.3.1.39': { friendlyName: 'preferredLanguage', categories: [] },
+ 'urn:oid:2.16.840.1.113730.3.1.241': { friendlyName: 'displayName', categories: ['rs'] },
+ // ── SCHAC ─────────────────────────────────────────────────────────────────────
+ 'urn:oid:1.3.6.1.4.1.25178.1.2.9': { friendlyName: 'schacHomeOrganization', categories: [] },
+ 'urn:oid:1.3.6.1.4.1.25178.1.2.10': { friendlyName: 'schacHomeOrganizationType', categories: [] },
+ 'urn:oid:1.3.6.1.4.1.25178.1.2.14': { friendlyName: 'schacPersonalUniqueCode', categories: [] },
+ 'urn:oid:1.3.6.1.4.1.25178.1.2.15': { friendlyName: 'schacPersonalUniqueID', categories: [] },
+ // ── Grouper / isMemberOf ──────────────────────────────────────────────────────
+ 'urn:oid:1.3.6.1.4.1.5923.1.5.1.1': { friendlyName: 'isMemberOf', categories: [] },
+};
+
+export function getAttributeInfo(name: string): AttributeInfo | undefined {
+ return ATTRIBUTE_REGISTRY[name];
+}
+
+// Returns 'scoped' if the ePPN value contains @scope, 'unscoped' if not, null if not an ePPN attribute.
+export function eppnScopedStatus(name: string, values: string[]): 'scoped' | 'unscoped' | null {
+ if (name !== EPPN_OID) return null;
+ if (values.length === 0) return null;
+ return values[0].includes('@') ? 'scoped' : 'unscoped';
+}
diff --git a/src/lib/examples.test.ts b/src/lib/examples.test.ts
new file mode 100644
index 0000000..1c789f0
--- /dev/null
+++ b/src/lib/examples.test.ts
@@ -0,0 +1,162 @@
+import { describe, it, expect } from 'vitest';
+import { EXAMPLES, EXAMPLE_CATEGORIES, type ExampleCategory } from './examples';
+import { decodeSaml } from './saml';
+import { decodeJwt } from './jwt';
+
+describe('EXAMPLES', () => {
+ it('has at least one entry for every declared category', () => {
+ const cats = new Set(EXAMPLES.map((e) => e.category));
+ for (const cat of EXAMPLE_CATEGORIES) {
+ expect(cats.has(cat), `missing category: ${cat}`).toBe(true);
+ }
+ });
+
+ it('every example generates a non-empty string', () => {
+ for (const ex of EXAMPLES) {
+ expect(ex.payload().length).toBeGreaterThan(0);
+ }
+ });
+
+ it('SAMLResponse examples decode without error via decodeSaml', () => {
+ for (const ex of EXAMPLES.filter((e) => e.category === 'SAMLResponse')) {
+ expect(() => decodeSaml(ex.payload())).not.toThrow();
+ }
+ });
+
+ it('SAMLResponse examples have Success status', () => {
+ for (const ex of EXAMPLES.filter((e) => e.category === 'SAMLResponse')) {
+ const result = decodeSaml(ex.payload());
+ expect(result.summary.status?.code).toContain('Success');
+ }
+ });
+
+ it('SAMLRequest examples decode without error via decodeSaml', () => {
+ for (const ex of EXAMPLES.filter((e) => e.category === 'SAMLRequest')) {
+ expect(() => decodeSaml(ex.payload())).not.toThrow();
+ }
+ });
+
+ it('SAMLRequest examples are identified as SAMLRequest message type', () => {
+ for (const ex of EXAMPLES.filter((e) => e.category === 'SAMLRequest')) {
+ const result = decodeSaml(ex.payload());
+ expect(result.summary.messageType).toBe('SAMLRequest');
+ }
+ });
+
+ it('Query string examples decode without error via decodeSaml', () => {
+ for (const ex of EXAMPLES.filter((e) => e.category === 'Query string')) {
+ expect(() => decodeSaml(ex.payload())).not.toThrow();
+ }
+ });
+
+ it('Full URL examples decode without error via decodeSaml', () => {
+ for (const ex of EXAMPLES.filter((e) => e.category === 'Full URL')) {
+ expect(() => decodeSaml(ex.payload())).not.toThrow();
+ }
+ });
+
+ it('JWT examples decode without error via decodeJwt', () => {
+ for (const ex of EXAMPLES.filter((e) => e.category === 'JWT')) {
+ expect(() => decodeJwt(ex.payload())).not.toThrow();
+ }
+ });
+
+ it('JWT examples have three dot-separated parts', () => {
+ for (const ex of EXAMPLES.filter((e) => e.category === 'JWT')) {
+ expect(ex.payload().split('.').length).toBe(3);
+ }
+ });
+
+ it('Authorization header examples decode without error via decodeJwt', () => {
+ for (const ex of EXAMPLES.filter((e) => e.category === 'Authorization header')) {
+ expect(() => decodeJwt(ex.payload())).not.toThrow();
+ }
+ });
+
+ it('Authorization header examples start with Authorization: Bearer', () => {
+ for (const ex of EXAMPLES.filter((e) => e.category === 'Authorization header')) {
+ expect(ex.payload()).toMatch(/^Authorization:\s+Bearer\s+/i);
+ }
+ });
+
+ it('Query string examples contain SAMLRequest or SAMLResponse parameter', () => {
+ for (const ex of EXAMPLES.filter((e) => e.category === 'Query string')) {
+ expect(ex.payload()).toMatch(/SAMLRequest=|SAMLResponse=/);
+ }
+ });
+
+ it('Full URL examples are valid HTTPS URLs', () => {
+ for (const ex of EXAMPLES.filter((e) => e.category === 'Full URL')) {
+ const payload = ex.payload();
+ expect(() => new URL(payload)).not.toThrow();
+ expect(new URL(payload).protocol).toBe('https:');
+ }
+ });
+
+ it('redirect-encoded examples use redirect binding', () => {
+ const qs = EXAMPLES.find(
+ (e) => e.category === 'Query string' && e.label.includes('Redirect')
+ );
+ const url = EXAMPLES.find((e) => e.category === 'Full URL');
+ expect(qs).toBeDefined();
+ expect(url).toBeDefined();
+ expect(decodeSaml(qs!.payload()).summary.binding).toBe('redirect');
+ expect(decodeSaml(url!.payload()).summary.binding).toBe('redirect');
+ });
+
+ it('POST binding example uses post binding', () => {
+ const post = EXAMPLES.find(
+ (e) => e.category === 'Query string' && e.label.includes('POST')
+ );
+ expect(post).toBeDefined();
+ expect(decodeSaml(post!.payload()).summary.binding).toBe('post');
+ });
+
+ it('MFA SAMLResponse has REFEDS MFA authn context', () => {
+ const mfa = EXAMPLES.find(
+ (e) => e.category === 'SAMLResponse' && e.label.includes('MFA')
+ );
+ expect(mfa).toBeDefined();
+ expect(decodeSaml(mfa!.payload()).summary.authnContext?.classRef).toBe(
+ 'https://refeds.org/profile/mfa'
+ );
+ });
+
+ it('MFA SAMLRequest has REFEDS MFA requested authn context', () => {
+ const mfa = EXAMPLES.find(
+ (e) => e.category === 'SAMLRequest' && e.label.includes('MFA')
+ );
+ expect(mfa).toBeDefined();
+ const result = decodeSaml(mfa!.payload());
+ expect(result.summary.requestedAuthnContext?.classRefs).toContain(
+ 'https://refeds.org/profile/mfa'
+ );
+ });
+
+ it('OIDC ID token example has typ JWT in header', () => {
+ const idToken = EXAMPLES.find((e) => e.label === 'OIDC ID token');
+ expect(idToken).toBeDefined();
+ const result = decodeJwt(idToken!.payload());
+ expect(result.header.typ).toBe('JWT');
+ });
+
+ it('access token example has typ at+JWT in header', () => {
+ const at = EXAMPLES.find((e) => e.label === 'OAuth 2.0 access token');
+ expect(at).toBeDefined();
+ const result = decodeJwt(at!.payload());
+ expect(result.header.typ).toBe('at+JWT');
+ });
+});
+
+describe('EXAMPLE_CATEGORIES', () => {
+ it('is an array of unique category strings', () => {
+ expect(new Set(EXAMPLE_CATEGORIES).size).toBe(EXAMPLE_CATEGORIES.length);
+ });
+
+ it('covers all categories used in EXAMPLES', () => {
+ const inExamples = new Set(EXAMPLES.map((e) => e.category));
+ for (const cat of inExamples) {
+ expect(EXAMPLE_CATEGORIES).toContain(cat);
+ }
+ });
+});
diff --git a/src/lib/examples.ts b/src/lib/examples.ts
new file mode 100644
index 0000000..0307eac
--- /dev/null
+++ b/src/lib/examples.ts
@@ -0,0 +1,243 @@
+import { deflateRaw } from 'pako';
+
+export type ExampleCategory =
+ | 'SAMLResponse'
+ | 'SAMLRequest'
+ | 'JWT'
+ | 'Authorization header'
+ | 'Query string'
+ | 'Full URL';
+
+export interface Example {
+ label: string;
+ category: ExampleCategory;
+ payload(): string;
+}
+
+export const EXAMPLE_CATEGORIES: ExampleCategory[] = [
+ 'SAMLResponse',
+ 'SAMLRequest',
+ 'JWT',
+ 'Authorization header',
+ 'Query string',
+ 'Full URL',
+];
+
+function ts(offsetSeconds: number): string {
+ return new Date(Date.now() + offsetSeconds * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
+}
+
+function encodeRedirectBinding(xml: string): string {
+ const bytes = new TextEncoder().encode(xml);
+ const compressed = deflateRaw(bytes) as Uint8Array;
+ let binary = '';
+ for (let i = 0; i < compressed.length; i++) {
+ binary += String.fromCharCode(compressed[i]);
+ }
+ return encodeURIComponent(btoa(binary));
+}
+
+function encodePostBinding(xml: string): string {
+ const bytes = new TextEncoder().encode(xml);
+ let binary = '';
+ for (let i = 0; i < bytes.length; i++) {
+ binary += String.fromCharCode(bytes[i]);
+ }
+ return btoa(binary);
+}
+
+function b64url(obj: object): string {
+ return btoa(JSON.stringify(obj))
+ .replace(/=/g, '')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_');
+}
+
+function makeJwt(header: object, payload: object): string {
+ const fakeSig = 'SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
+ return `${b64url(header)}.${b64url(payload)}.${fakeSig}`;
+}
+
+function samlResponse(opts: { mfa?: boolean } = {}): string {
+ const authnContextRef = opts.mfa
+ ? 'https://refeds.org/profile/mfa'
+ : 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport';
+
+ return `
+
+ https://idp.university.edu/idp/shibboleth
+
+
+
+
+ https://idp.university.edu/idp/shibboleth
+
+ _transient_7f2a3b4c5d6e7f8a9b0c
+
+
+
+
+
+
+ https://app.example.com/shibboleth
+
+
+
+
+ ${authnContextRef}
+
+
+
+
+ jsmith@university.edu
+
+
+ j.smith@university.edu
+
+
+ Jane Smith
+
+
+ Jane
+
+
+ Smith
+
+
+ member@university.edu
+ staff@university.edu
+
+
+ university.edu
+
+
+
+`;
+}
+
+function samlRequest(opts: { mfa?: boolean } = {}): string {
+ const mfaBlock = opts.mfa
+ ? `\n \n https://refeds.org/profile/mfa\n `
+ : '';
+ return `
+
+ https://app.example.com/shibboleth
+ ${mfaBlock}
+`;
+}
+
+function idTokenPayload(): object {
+ const now = Math.floor(Date.now() / 1000);
+ return {
+ iss: 'https://idp.university.edu',
+ sub: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
+ aud: 'client_abc123',
+ iat: now - 30,
+ exp: now + 3570,
+ nonce: 'n-0S6_WzA2Mj',
+ amr: ['pwd', 'mfa'],
+ name: 'Jane Smith',
+ email: 'jsmith@university.edu',
+ preferred_username: 'jsmith',
+ };
+}
+
+function accessTokenPayload(): object {
+ const now = Math.floor(Date.now() / 1000);
+ return {
+ iss: 'https://auth.example.com',
+ sub: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
+ aud: 'api.example.com',
+ iat: now - 15,
+ exp: now + 3585,
+ jti: 'jwt_6f84bc2a9d1e3057',
+ scope: 'openid email profile',
+ client_id: 'app_xyz789',
+ };
+}
+
+export const EXAMPLES: Example[] = [
+ {
+ label: 'Successful response (password auth)',
+ category: 'SAMLResponse',
+ payload: () => samlResponse(),
+ },
+ {
+ label: 'Successful response (REFEDS MFA)',
+ category: 'SAMLResponse',
+ payload: () => samlResponse({ mfa: true }),
+ },
+ {
+ label: 'Basic authentication request',
+ category: 'SAMLRequest',
+ payload: () => samlRequest(),
+ },
+ {
+ label: 'Request requiring REFEDS MFA',
+ category: 'SAMLRequest',
+ payload: () => samlRequest({ mfa: true }),
+ },
+ {
+ label: 'OIDC ID token',
+ category: 'JWT',
+ payload: () => makeJwt({ alg: 'RS256', kid: 'sig-2024-01', typ: 'JWT' }, idTokenPayload()),
+ },
+ {
+ label: 'OAuth 2.0 access token',
+ category: 'JWT',
+ payload: () =>
+ makeJwt({ alg: 'RS256', kid: 'sig-2024-01', typ: 'at+JWT' }, accessTokenPayload()),
+ },
+ {
+ label: 'Bearer token',
+ category: 'Authorization header',
+ payload: () =>
+ `Authorization: Bearer ${makeJwt({ alg: 'RS256', kid: 'sig-2024-01', typ: 'JWT' }, idTokenPayload())}`,
+ },
+ {
+ label: 'Redirect binding query string',
+ category: 'Query string',
+ payload: () =>
+ `SAMLRequest=${encodeRedirectBinding(samlRequest())}&RelayState=%2Fdashboard`,
+ },
+ {
+ label: 'POST binding form value',
+ category: 'Query string',
+ payload: () => `SAMLResponse=${encodeURIComponent(encodePostBinding(samlResponse()))}`,
+ },
+ {
+ label: 'IdP redirect binding URL',
+ category: 'Full URL',
+ payload: () =>
+ `https://idp.university.edu/idp/profile/SAML2/Redirect/SSO?SAMLRequest=${encodeRedirectBinding(samlRequest())}&RelayState=%2Fdashboard`,
+ },
+];
diff --git a/src/lib/explanations.ts b/src/lib/explanations.ts
index ef4886b..ace5652 100644
--- a/src/lib/explanations.ts
+++ b/src/lib/explanations.ts
@@ -40,7 +40,7 @@ export const FIELD_EXPLANATIONS: Record = {
'The SP\'s requirements for how the user must authenticate. The Comparison attribute controls matching: "exact" requires one of the listed classes precisely; "minimum" accepts any class at least as strong; "better" requires strictly stronger; "maximum" requires no stronger. If the IdP cannot satisfy the requirement, it returns NoAuthnContext.',
'saml.attributes':
- 'Attribute statements contain claims about the authenticated user released by the IdP — such as email, display name, group memberships, or entitlements. Which attributes are released is governed by IdP policy and the SP\'s metadata. These values typically drive authorization decisions at the SP.',
+ 'Attribute statements contain claims about the authenticated user released by the IdP — such as email, display name, group memberships, or entitlements. Which attributes are released is governed by IdP policy and the SP\'s metadata. These values typically drive authorization decisions at the SP. Badges: R&S = part of the REFEDS Research & Scholarship attribute bundle (applies to IdPs and SPs that have registered the R&S entity category in their metadata); eppn-scoped = eduPersonPrincipalName value is properly scoped (user@scope) per REFEDS RAF eppn-unique requirements.',
// ── SAML timestamps ───────────────────────────────────────────────────────
'saml.ts.authnInstant':
From c0b1677eaf0907430e8781b98f9b9ce88b6a4b33 Mon Sep 17 00:00:00 2001
From: Kellen Murphy
Date: Mon, 25 May 2026 14:37:50 -0400
Subject: [PATCH 2/4] feat(ui): add attribute badge UI and Examples button with
10 dynamic payloads
Attribute table:
- Show R&S and eppn-scoped/unscoped badges below OID in the attribute name column
- Fill friendly name from registry when assertion omits FriendlyName attribute
Examples button (always visible, top-right of the decoder):
- Dropdown grouped by input category: SAMLResponse, SAMLRequest, JWT,
Authorization header, Query string, Full URL
- Each example is generated fresh on click with live timestamps via pako deflateRaw
(redirect binding) and btoa (POST binding), so relative time labels are always current
- Click outside closes the dropdown; chevron rotates when open
- Copy link appears alongside Examples when content is loaded
---
src/routes/+page.svelte | 102 ++++++++++++++++++++++++++++++++++++----
1 file changed, 92 insertions(+), 10 deletions(-)
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 5db76b4..e6ac73e 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -6,9 +6,11 @@
import InfoTip from '$lib/InfoTip.svelte';
import { SAML_TS_KEY } from '$lib/explanations';
import { encodePayload, decodePayload } from '$lib/hash';
+ import { getAttributeInfo, eppnScopedStatus } from '$lib/attributes';
import { relativeLabel, isExpired } from '$lib/time';
import type { CertInfo } from '$lib/cert';
import { highlightXml, XML_ELEMENT_TIPS } from '$lib/xml-highlight';
+ import { EXAMPLES, EXAMPLE_CATEGORIES } from '$lib/examples';
type InputType = 'saml' | 'jwt' | null;
@@ -27,6 +29,19 @@
let discoveryLoading = $state(false);
let discoveryError = $state(null);
let discoveryGeneration = 0;
+ let showExamples = $state(false);
+ let examplesRef: HTMLElement | undefined;
+
+ $effect(() => {
+ if (!showExamples) return;
+ const handleClick = (e: MouseEvent) => {
+ if (!examplesRef?.contains(e.target as Node)) {
+ showExamples = false;
+ }
+ };
+ document.addEventListener('mousedown', handleClick);
+ return () => document.removeEventListener('mousedown', handleClick);
+ });
const discoveryIssuerMatch = $derived.by(() => {
if (!discoveryResult || !jwtResult) return null;
@@ -277,12 +292,58 @@
Paste a SAML assertion, JWT, query string, URL, or HTTP log line. We'll figure it out and try to explain it!
- {#if hasResults}
+
- {/if}
+ onclick={() => (showExamples = !showExamples)}
+ class="flex items-center gap-1 rounded-md px-3 py-1.5 text-sm text-neutral-500 ring-1 ring-neutral-300 transition-colors hover:bg-neutral-100 hover:text-neutral-700 dark:ring-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-300"
+ >
+ Examples
+
+
+ {#if hasResults}
+
+ {/if}
+ {#if showExamples}
+
+ {#each EXAMPLE_CATEGORIES as category, i (category)}
+ {@const catExamples = EXAMPLES.filter((e) => e.category === category)}
+ {#if catExamples.length > 0}
+ {#if i > 0}
+
+ {/if}
+
+ {#each catExamples as example (example.label)}
+
+ {/each}
+
+ {/if}
+ {/each}
+
+ {/if}
+