Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ labels: bug
A clear description of the bug.

**Input type**

- [ ] SAMLRequest
- [ ] SAMLResponse
- [ ] JWT
- [ ] Other

**Steps to reproduce**

1. Paste ...
2. See ...

Expand All @@ -24,6 +26,7 @@ What did you expect to see?
What did you see instead?

**Environment**

- Browser:
- OS:

Expand Down
174 changes: 72 additions & 102 deletions CHANGELOG.md

Large diffs are not rendered by default.

23 changes: 15 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ The core UX premise: paste whatever messy thing you grabbed from a log file, a n
A single `<textarea>` input. The `detect()` function determines whether the input is a JWT (three-part dot-separated base64url, or starts with `Authorization: Bearer`) or SAML (everything else). A debounced `$effect` drives decoding on a 300ms timer.

The page handles:

- Multiple SAML messages in one paste (`decodeAllSaml` scans for all `SAMLRequest`/`SAMLResponse` params)
- Generic fallback decoding (`decodeAllGeneric`) for non-SAML base64/XML/JSON blobs
- Lazy-loaded cert decoding via dynamic `import('$lib/cert')` to avoid blocking first render
Expand Down Expand Up @@ -60,6 +61,7 @@ The page handles:
### JWT decode library (`src/lib/jwt.ts`)

`decodeJwt(input)` — strips `Bearer` prefix, splits on `.`, base64url-decodes header and payload, returns typed `JwtDecodeResult` with:

- `isAlgNone` / `isWeakAlg` flags
- `timestamps` object (`iat`, `exp`, `nbf` as `JwtTimestamp` with `date`, `label`, `expired`)
- `scopes` array (handles both `scope` string and `scp` array claim variants)
Expand Down Expand Up @@ -105,6 +107,7 @@ All tooltip text externalized as `FIELD_EXPLANATIONS: Record<string, string>` wi
## Test coverage

100% statements, branches, functions, and lines across all `src/lib/` modules. Achieved via:

- `/* v8 ignore next */` directives on defensive null-guards that are unreachable in valid DER/XML (never in `parseName`, `parseKeyAlg`, etc.)
- Carefully crafted DER certificate fixtures (single-line base64, length divisible by 4 — jsdom's `atob()` is strict, unlike Node's `Buffer.from()`)
- Test cases for every edge path: UTCTime year ≥ 50, EC with no curve OID, RSA modulus without leading zero, unknown DN attribute OIDs, all SAML timestamp variants, JWT non-string alg, etc.
Expand Down Expand Up @@ -135,10 +138,12 @@ Run with: `npm run coverage`
## Planned features

### Medium priority

- **SAML signature validation** — fetch the IdP's SAML metadata (by EntityID or URL), extract the signing cert, and verify `<ds:Signature>` using the Web Crypto API. Display verified/unverified status prominently.
- **JWT JWKS validation** — after OIDC discovery, fetch `jwks_uri` and attempt to verify the JWT signature against the matching key.

### Lower priority / ideas

- **MDQ discovery** — fetch an entity's metadata from InCommon's MDQ service by EntityID and feed it into the metadata view (`src/lib/metadata.ts`)
- **REFEDS entity category checker** — cross-check an SP's `RequestedAttribute`s against entity categories already parsed by the metadata view
- **i18n** — `explanations.ts` is already structured for this; add a locale switcher and alternate record implementations
Expand All @@ -157,15 +162,15 @@ Optional body explaining why, not what.

### Types and their effects

| Type | Version bump | Changelog section |
|------|-------------|-------------------|
| `feat` | minor (1.x.0) | Features |
| `fix` | patch (1.0.x) | Bug Fixes & Dependencies |
| `perf` | patch | Performance |
| Type | Version bump | Changelog section |
| ---------- | ------------------- | ---------------------------------------- |
| `feat` | minor (1.x.0) | Features |
| `fix` | patch (1.0.x) | Bug Fixes & Dependencies |
| `perf` | patch | Performance |
| `security` | none (display only) | Security — use `fix` if a bump is needed |
| `docs` | none | Documentation (visible) |
| `chore` | none | Miscellaneous (hidden) |
| `ci` | none | CI/CD (hidden) |
| `docs` | none | Documentation (visible) |
| `chore` | none | Miscellaneous (hidden) |
| `ci` | none | CI/CD (hidden) |

Breaking changes: add `!` after the type (`feat!:` or `fix!:`) **or** include a `BREAKING CHANGE:` footer. Either triggers a major bump (x.0.0).

Expand All @@ -184,9 +189,11 @@ Commits that don't match any of the above types (e.g. `Add something` with no ty
Before any structural refactor on a file >300 LOC, remove dead props, unused exports, and debug logs. Commit separately.

After any significant change, run:

```bash
npx tsc --noEmit && npx eslint . --quiet && npm run coverage
```

All three must pass before reporting work complete.

ESLint config note: `svelte/no-navigation-without-resolve` is configured with `ignoreLinks: true` because samlguy.com deploys at the root with no base path, making `resolve()` unnecessary for plain `href="/"` links.
Expand Down
15 changes: 4 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<div align="center">

# `<saml:Guy/>`
**[*samlguy.com*](https://samlguy.com) — A SAML assertion and JWT decoder for the IAM community.**

**[_samlguy.com_](https://samlguy.com) — A SAML assertion and JWT decoder for the IAM community.**

[![CI](https://github.com/kellenmurphy/samlguy/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/kellenmurphy/samlguy/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/kellenmurphy/samlguy/graph/badge.svg)](https://codecov.io/gh/kellenmurphy/samlguy) [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/12859/badge)](https://www.bestpractices.dev/projects/12859)

Expand All @@ -13,10 +14,9 @@ SAML assertions get a full summary panel with binding type, message type, status

Think [jwt.io](https://jwt.io), but for the full modern IAM stack — built by someone who actually works in identity, for everyone else who does too.


## Who is it for?

[samlguy.com](https://samlguy.com) is built by an IAM practitioner, for the IAM practitioner community: identity architects and engineers at universities, research institutions, and other organizations participating in federated identity like **[InCommon](https://incommon.org)** and **[eduGAIN](https://edugain.org/)**, and the broader R&E Trust & Identity community.
[samlguy.com](https://samlguy.com) is built by an IAM practitioner, for the IAM practitioner community: identity architects and engineers at universities, research institutions, and other organizations participating in federated identity like **[InCommon](https://incommon.org)** and **[eduGAIN](https://edugain.org/)**, and the broader R&E Trust & Identity community.

If you run a Shibboleth IdP, debug SAML flows for a living, or spend time staring at raw base64 blobs in Fiddler or a browser DevTools network tab — this tool belongs in your bookmarks.

Expand All @@ -34,14 +34,12 @@ But it's not just SAML! It's equally useful for OAuth 2.0 / OIDC work. Access to

**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.


## Privacy & Security

**All SAML and JWT payloads are decoded entirely in your browser.** Nothing you paste is ever transmitted to or stored on any server. The only network request the tool makes on your behalf is the OIDC discovery proxy described above, which receives only an issuer URL, not the token itself.

See [SECURITY.md](SECURITY.md) for the full vulnerability disclosure policy and a description of the security controls in this project.


## Local development

```bash
Expand All @@ -63,7 +61,6 @@ npm run build # production build
npm run preview # preview production build locally
```


## Project structure

```
Expand Down Expand Up @@ -96,12 +93,10 @@ src/
CODEOWNERS
```


## Deployment

[samlguy.com](https://samlguy.com) is deployed using Cloudflare Pages. Every merge to `main` triggers an automatic build and deploy via GitHub Actions — but only after the test and coverage jobs pass. Build command: `npm run build`, output directory: `.svelte-kit/cloudflare`.


## What's Planned

- **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
Expand All @@ -111,12 +106,10 @@ src/
- **JWT JWKS validation** — after OIDC discovery, fetch `jwks_uri` and verify the JWT signature against the matching key
- **REFEDS entity category checker** — given an EntityID (via MDQ), check whether the IdP's attribute release policy would likely cover the SP's requested attributes; builds on the entity-category and requested-attribute parsing already in the metadata view


## Contributing

This started as a personal passion project for [The SAML Guy](https://kellenmurphy.com) to mess with a nifty tech stack (Svelte). Bug reports and suggestions are welcome via [GitHub Issues](https://github.com/kellenmurphy/samlguy/issues). See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and contribution guidelines. For security vulnerabilities, please follow the [responsible disclosure process](SECURITY.md) rather than opening a public issue.


## License

[MIT](LICENSE) — free to use, modify, and distribute. Attribution appreciated but not required.
[MIT](LICENSE) — free to use, modify, and distribute. Attribution appreciated but not required.
24 changes: 14 additions & 10 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@ You can expect an acknowledgement within 48 hours and a resolution or status upd

### Adversaries and assets

| Adversary | Goal | Primary controls |
|---|---|---|
| Malicious paste / shared link | Trigger XSS via crafted SAML or JWT content rendered in the DOM | Svelte's default text escaping, `esc()` in the XML highlighter, CSP (`default-src 'self'`, no `unsafe-inline`, hash-pinned inline scripts) |
| Compromised npm package | Inject malicious code into the build or CI environment | GuardDog, `npm audit`, Grype, Dependency Review, SHA-pinned actions, `npm ci` lockfile enforcement |
| Compromised upstream GitHub Action | Substitute malicious CI code via a tampered version tag | All actions pinned to commit SHA, Dependabot rotates pins daily |
| SSRF via OIDC proxy | Use the discovery Worker to reach internal infrastructure | `redirect: 'error'`, 5-second timeout, 100 KB cap, response validation |
| Compromised maintainer account | Push unsigned or unreviewed code to `main` | Required commit signatures, branch protection, CODEOWNERS review, scoped API tokens |
| Information leakage | Exfiltrate SAML assertions or JWT payloads | All decoding is client-side; no server ever receives token content; Worker code logs nothing; `Referrer-Policy: no-referrer` prevents issuer URLs from leaking via outbound navigation |
| Adversary | Goal | Primary controls |
| ---------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Malicious paste / shared link | Trigger XSS via crafted SAML or JWT content rendered in the DOM | Svelte's default text escaping, `esc()` in the XML highlighter, CSP (`default-src 'self'`, no `unsafe-inline`, hash-pinned inline scripts) |
| Compromised npm package | Inject malicious code into the build or CI environment | GuardDog, `npm audit`, Grype, Dependency Review, SHA-pinned actions, `npm ci` lockfile enforcement |
| Compromised upstream GitHub Action | Substitute malicious CI code via a tampered version tag | All actions pinned to commit SHA, Dependabot rotates pins daily |
| SSRF via OIDC proxy | Use the discovery Worker to reach internal infrastructure | `redirect: 'error'`, 5-second timeout, 100 KB cap, response validation |
| Compromised maintainer account | Push unsigned or unreviewed code to `main` | Required commit signatures, branch protection, CODEOWNERS review, scoped API tokens |
| Information leakage | Exfiltrate SAML assertions or JWT payloads | All decoding is client-side; no server ever receives token content; Worker code logs nothing; `Referrer-Policy: no-referrer` prevents issuer URLs from leaking via outbound navigation |

### Out of scope

Expand Down Expand Up @@ -130,6 +130,7 @@ The adapter additionally appends `Cache-Control: public, immutable, max-age=3153
All GitHub Actions are pinned to a full commit SHA, not a mutable version tag. Version tags can be silently redirected by a compromised upstream repository, substituting malicious code into the pipeline. Pinning by SHA means the exact code reviewed at setup time is the code that runs. Human-readable version numbers are preserved as inline comments.

Current pins in `.github/workflows/ci.yml`:

- `actions/checkout` — SHA-pinned
- `actions/setup-node` — SHA-pinned
- `astral-sh/setup-uv` — SHA-pinned (v8.1.0)
Expand All @@ -142,15 +143,18 @@ Current pins in `.github/workflows/ci.yml`:
- `actions/attest-build-provenance` — SHA-pinned (v4.1.0)

Current pins in `.github/workflows/scorecard.yml`:

- `actions/checkout` — SHA-pinned
- `ossf/scorecard-action` — SHA-pinned (v2.4.3)
- `actions/upload-artifact` — SHA-pinned (v7.0.1)
- `github/codeql-action/upload-sarif` — SHA-pinned

Current pins in `.github/workflows/release-please.yml`:

- `googleapis/release-please-action` — SHA-pinned (v4)

Current pins in `.github/workflows/codeql.yml`:

- `actions/checkout` — SHA-pinned
- `github/codeql-action/init` — SHA-pinned (v3.28.13)
- `github/codeql-action/analyze` — SHA-pinned (v3.28.13)
Expand Down Expand Up @@ -186,6 +190,7 @@ GitHub secret scanning and push protection are both enabled on this repository.
### Dependabot

Dependabot runs daily for:

- **npm packages** — grouped (Svelte ecosystem together, Cloudflare tools together) to reduce PR noise while keeping everything current. Commits use the `chore(deps)` prefix so routine dependency bumps do not trigger unnecessary patch releases via release-please.
- **GitHub Actions** — separate ecosystem entry, because action dependencies are a supply chain vector that is easy to neglect

Expand Down Expand Up @@ -215,7 +220,7 @@ Alongside the `Dependency Audit` job (push, weekly schedule, and manual dispatch

### Dependency Review

The [dependency-review-action](https://github.com/actions/dependency-review-action) runs on pull requests only. It compares the dependency diff introduced by the PR against GitHub's vulnerability database and fails the check if any newly added package carries a moderate or higher CVE. This is the pull-request-time vulnerability gate: it catches vulnerable dependencies a PR would *introduce*, before they land in `main`, while the full-tree `npm audit` and Grype scans run on push and the weekly schedule. A PR is therefore never blocked by a pre-existing, tree-wide advisory in a dependency the PR did not touch.
The [dependency-review-action](https://github.com/actions/dependency-review-action) runs on pull requests only. It compares the dependency diff introduced by the PR against GitHub's vulnerability database and fails the check if any newly added package carries a moderate or higher CVE. This is the pull-request-time vulnerability gate: it catches vulnerable dependencies a PR would _introduce_, before they land in `main`, while the full-tree `npm audit` and Grype scans run on push and the weekly schedule. A PR is therefore never blocked by a pre-existing, tree-wide advisory in a dependency the PR did not touch.

### GitHub Code Scanning

Expand Down Expand Up @@ -266,4 +271,3 @@ Since samlguy.com deploys continuously from `main`, the production deployment al
## License

This project is released under the [MIT License](LICENSE). You are free to use, fork, modify, and distribute the code. The only requirement is that the copyright notice is preserved in copies or substantial portions of the software.

Loading
Loading