diff --git a/README.md b/README.md
index 16de633..4a417fb 100644
--- a/README.md
+++ b/README.md
@@ -24,11 +24,11 @@ But it's not just SAML! It's equally useful for OAuth 2.0 / OIDC work. Access to
## Features
-**Input** — paste just about anything: raw base64+DEFLATE or base64 blob, query string, full URL, raw HTTP log line, JWT, or `Authorization: Bearer` header. Multiple SAML messages in one paste are handled. RelayState is always extracted separately; double URL-encoding is unwound automatically. `ctrl-a` in your logs and paste away — it'll make sense of it.
+**Input** — paste just about anything: raw base64+DEFLATE or base64 blob, query string, full URL, raw HTTP log line, JWT, or `Authorization: Bearer` header. Multiple SAML messages in one paste are handled. RelayState is always extracted separately; double URL-encoding is unwound automatically. `ctrl-a` in your logs and paste away — it'll make sense of it. An **Examples** button loads any of 10 pre-built, dynamically-generated payloads (SAMLResponse, SAMLRequest, JWT, Authorization header, query string, POST binding form value, and full redirect URL) as one-click starting points.
-**SAML** — binding type, message type, status with human-readable description and spec link, issuer, NameID, timestamps with relative labels ("expired 3 hours ago"), AuthnContext class reference with friendly label and assurance-level tooltip (OASIS, REFEDS, RAF, NIST), attribute table, signing cert details (key algorithm, validity), and syntax-highlighted XML with hover tips on all known SAML element names.
+**SAML** — binding type, message type, status with human-readable description and spec link, issuer, NameID, timestamps with relative labels ("expired 3 hours ago"), AuthnContext class reference with friendly label and assurance-level tooltip (OASIS, REFEDS, RAF, NIST), attribute table with **InCommon attribute annotations** (R&S and eppn-scoped/unscoped badges from an embedded attribute registry; friendly names filled in even when the assertion omits them), signing cert details (key algorithm, validity), and syntax-highlighted XML with hover tips on all known SAML element names.
-**JWT** — algorithm with safety flags (`alg: none` danger badge, HMAC weak badge), claims summary, timestamps with relative labels, scope/scp badge list, raw JSON header and payload. OIDC discovery fetches the issuer's `.well-known/openid-configuration` server-side (Cloudflare Worker, avoids CORS) and checks algorithm support against the token's `alg`.
+**JWT** — algorithm with safety flags (`alg: none` danger badge, HMAC weak badge), claims summary, timestamps with relative labels, scope/scp badge list, raw JSON header and payload. OIDC discovery fetches the issuer's `.well-known/openid-configuration` server-side (Cloudflare Worker, avoids CORS) and checks algorithm support against the token's `alg`. Accepts bare JWTs, `Bearer `, or full `Authorization: Bearer ` header lines.
**Everything else** — contextual `?` tooltips on every field covering the SAML spec, JWT/OIDC standards, and trust fabric conventions. Shareable links base64url-encode the input into the URL fragment — never sent to the server. Dark mode default with `localStorage` persistence.
@@ -74,6 +74,8 @@ src/
generic.ts # fallback decoder for unrecognized base64/JSON/XML blobs
hash.ts # base64url encode/decode for shareable URL fragments
xml-highlight.ts # custom XML tokenizer: syntax-colored HTML spans + element tooltips
+ attributes.ts # InCommon attribute registry: R&S categories, eppn-scoped detection
+ examples.ts # 10 dynamically-generated example payloads (SAML, JWT, query strings, URLs)
explanations.ts # externalized hover tooltip text for all summary fields
InfoTip.svelte # hover tooltip component
routes/
@@ -98,7 +100,6 @@ src/
## What's Planned
-- **InCommon attribute annotations** — flag attributes in the attribute table as InCommon Baseline Eligible, R&S, or REFEDS RAF/eppn-scoped; a clear differentiator vs generic SAML tools
- **Diff view** — paste two assertions side-by-side and highlight what changed; most useful for attribute table and timestamp diffs when debugging why a second login attempt looks different
- **SAML metadata parsing** — parse `EntityDescriptor` XML into a structured view: signing certs, ACS URLs, NameID formats, supported bindings, contacts
- **MDQ discovery** — "Discover" button on the SAML Issuer row fetches the IdP's metadata from InCommon's MDQ service (`https://mdq.incommon.org/entities/{entityID}`) — no aggregate download needed; optional MDQ base URL for other federations (eduGAIN, etc.)
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':
diff --git a/src/lib/jwt.test.ts b/src/lib/jwt.test.ts
index e944173..c657c12 100644
--- a/src/lib/jwt.test.ts
+++ b/src/lib/jwt.test.ts
@@ -105,6 +105,12 @@ describe('decodeJwt — Bearer prefix', () => {
const result = decodeJwt('bearer ' + RS256_FULL);
expect(result.raw).toBe(RS256_FULL);
});
+
+ it('strips full "Authorization: Bearer " HTTP header line', () => {
+ const result = decodeJwt('Authorization: Bearer ' + RS256_FULL);
+ expect(result.raw).toBe(RS256_FULL);
+ expect(result.header.alg).toBe('RS256');
+ });
});
describe('decodeJwt — alg:none', () => {
diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts
index 74e4e57..12960fb 100644
--- a/src/lib/jwt.ts
+++ b/src/lib/jwt.ts
@@ -50,7 +50,7 @@ function parseScopes(payload: Record): string[] | null {
}
export function decodeJwt(input: string): JwtDecodeResult {
- const trimmed = input.trim().replace(/^Bearer\s+/i, '');
+ const trimmed = input.trim().replace(/^Authorization:\s+/i, '').replace(/^Bearer\s+/i, '');
const parts = trimmed.split('.');
if (parts.length !== 3)
throw new Error('Invalid JWT: expected 3 dot-separated parts');
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}
+