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
6 changes: 6 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ updates:
schedule:
interval: 'weekly'
open-pull-requests-limit: 10
commit-message:
prefix: 'fix'
include: 'scope'
groups:
svelte:
patterns:
Expand All @@ -20,3 +23,6 @@ updates:
schedule:
interval: 'weekly'
open-pull-requests-limit: 5
commit-message:
prefix: 'chore'
include: 'scope'
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened, labeled]
workflow_dispatch:

permissions:
contents: read
Expand Down
9 changes: 6 additions & 3 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ jobs:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json

- name: Approve and enable auto-merge on release PR
- name: Trigger CI, approve, and enable auto-merge on release PR
if: ${{ steps.release.outputs.pr }}
env:
GH_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
run: |
gh pr review "${{ steps.release.outputs.pr }}" --approve --body "Approved by release-please automation."
gh pr merge --auto --merge "${{ steps.release.outputs.pr }}"
PR_NUMBER=$(echo '${{ steps.release.outputs.pr }}' | jq -r '.number')
PR_BRANCH=$(echo '${{ steps.release.outputs.pr }}' | jq -r '.headBranchName')
gh workflow run ci.yml --ref "$PR_BRANCH"
gh pr review "$PR_NUMBER" --approve --body "Approved by release-please automation."
gh pr merge --auto --merge "$PR_NUMBER"
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Changelog

## [1.0.0](https://github.com/kellenmurphy/samlguy/releases/tag/v1.0.0) (2026-05-16)

### Features

* Smart input detection — paste raw base64+DEFLATE (HTTP-Redirect), raw base64 (HTTP-POST), query strings, full URLs, HTTP log lines, or `Authorization: Bearer` headers; the tool figures out what it is
* SAML decoder with summary panel (binding, message type, status, issuer, destination, ACS URL, NameID, InResponseTo, RelayState, encryption flags), timestamp math, signing certificate details (subject, issuer, key algorithm, validity), attribute statement table, and pretty-printed XML
* JWT / OIDC decoder with algorithm warnings (`alg: none` danger, HMAC weak badge), timestamp analysis, scope display, and raw header/payload JSON
* X.509 certificate parser — pure DER/ASN.1 implementation, no external library; handles RSA, EC, UTCTime, GeneralizedTime, and unknown OIDs
* OIDC Discovery — Discover button on the `iss` field fetches `.well-known/openid-configuration` server-side via Cloudflare Worker; surfaces issuer match, supported algorithms, JWKS URI, and endpoint URLs
* Shareable links — Copy link button encodes the current input into the URL fragment (`#`); the fragment never reaches the server
* InfoTip contextual help tooltips on every summary field, covering SAML spec, JWT/OIDC standards, OAuth 2.0 scopes, and common federation conventions
* Light / dark mode toggle with `localStorage` persistence
* Privacy policy page

### Bug Fixes & Dependencies

* Fix overscroll background flash on mobile
* Fix coverage reporters: configure lcov output in vitest config rather than CLI flag
* Fix devalue and svelte audit vulnerabilities
* Bump all dependencies to latest

### Security

* OSSF Scorecard workflow — weekly evaluation of supply-chain security practices, results published to GitHub code scanning dashboard
* CodeQL advanced setup — `security-extended` query suite on every push and PR to `main`
* SHA-pinned GitHub Actions across all workflows
* Commit signing via SSH key (1Password agent); all commits to `main` carry verified signatures
* Property-based fuzz tests (fast-check) covering all parsers: SAML decoder, JWT decoder, X.509 DER/ASN.1 parser, generic fallback decoder
* SECURITY.md: vulnerability reporting via GitHub private advisory; full disclosure of data flow, supply chain controls, branch protection, and fuzzing approach

### Documentation

* MIT license
49 changes: 41 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ The page handles:

### X.509 certificate parser (`src/lib/cert.ts`)

**Pure custom DER/ASN.1 parser — no external library.** (`@peculiar/x509` is in package.json but is unused and should be removed.) Parses: subject DN, issuer DN, validity dates (both UTCTime and GeneralizedTime), serial number, and key algorithm (RSA with bit length, EC with named curve). Handles unknown OIDs gracefully.
**Pure custom DER/ASN.1 parser — no external library.** Parses: subject DN, issuer DN, validity dates (both UTCTime and GeneralizedTime), serial number, and key algorithm (RSA with bit length, EC with named curve). Handles unknown OIDs gracefully.

### Time helpers (`src/lib/time.ts`)

Expand All @@ -63,12 +63,16 @@ The page handles:

### OIDC discovery proxy (`src/routes/api/discover/+server.ts`)

Cloudflare Worker. Accepts `?issuer=<url>`, validates HTTPS, fetches `{issuer}/.well-known/openid-configuration`, returns the discovery document. Rejects non-HTTPS issuers with HTTP 400. **The API is deployed but the JWT UI does not yet call it**wiring up the UI is a planned feature.
Cloudflare Worker. Accepts `?issuer=<url>`, validates HTTPS, fetches `{issuerUrl.href}/.well-known/openid-configuration`, returns the discovery document. Rejects non-HTTPS issuers with HTTP 400. Called from the JWT results panel when `iss` is presentsurfaces issuer match and supported algorithm checks against the decoded token.

### InfoTip component (`src/lib/InfoTip.svelte`)

Hover tooltip on every summary field. Renders a small `?` button; on hover/focus, calculates the button's `getBoundingClientRect()` and renders the tooltip using `position: fixed` to escape the `overflow-hidden` card containers (absolute positioning would be clipped). The tooltip resets `uppercase`, `tracking-wider`, and `font-semibold` via `normal-case tracking-normal font-normal` to handle inheriting from section header spans.

### Shareable link codec (`src/lib/hash.ts`)

`encodePayload(s)` — UTF-8 encodes a string, base64url-encodes the bytes (no padding). `decodePayload(s)` — reverses this. Used in `+page.svelte`: on `onMount`, reads `window.location.hash`, decodes it into the textarea input (wrapped in try/catch for malformed hashes). The "Copy link" button calls `encodePayload(input)` and writes `origin + pathname + '#' + encoded` to the clipboard. The hash fragment is never sent to the server.

### Explanations (`src/lib/explanations.ts`)

All tooltip text externalized as `FIELD_EXPLANATIONS: Record<string, string>` with dotted-namespace keys (`saml.binding`, `jwt.algorithm`, `saml.ts.notOnOrAfter`, etc.). Also exports `SAML_TS_KEY` to map dynamic SAML timestamp labels (from `parseSummary`) to their explanation keys. Designed to be i18n-ready — a translation is just an alternate implementation of the same record shape.
Expand Down Expand Up @@ -105,22 +109,51 @@ Run with: `npm run coverage`

## Planned features

### High priority
- **OIDC Discovery UI** — the `/api/discover` endpoint is live; the JWT results panel needs a "Fetch discovery" button (or auto-trigger when `iss` is present). Should surface `issuer`, `jwks_uri`, `authorization_endpoint`, supported algs/scopes, and flag any inconsistencies with the decoded token's claims.
- **Shareable links** — encode the paste content into the URL fragment (`window.location.hash`). A "Copy link" button generates a URL that reopens the tool with the payload pre-loaded. Fragment never hits the server. Useful for async troubleshooting with colleagues.

### 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.
- **Light mode toggle** — dark mode is current default; add a toggle with `localStorage` persistence.
- **XML syntax highlighting** — color-code element names, attribute names, and values in the XML `<pre>` block. Could use a small tokenizer or a lightweight library like `highlight.js` scoped to XML.
- **JWT JWKS validation** — after OIDC discovery, fetch `jwks_uri` and attempt to verify the JWT signature against the matching key.

### Lower priority / ideas
- **SAML metadata parsing** — accept EntityDescriptor XML, display SP/IdP metadata in a structured way (ACS URLs, NameID formats, signing certs, attribute requirements)
- **JWT JWKS validation** — after OIDC discovery, fetch `jwks_uri` and attempt to verify the JWT signature using the matching key
- **i18n** — `explanations.ts` is already structured for this; add a locale switcher and alternate record implementations

---

## Commit message conventions

This project uses [Conventional Commits](https://www.conventionalcommits.org/). Release-please reads commit messages to determine version bumps and populate `CHANGELOG.md`. Every commit to `main` (directly or via PR merge) must follow this format:

```
type(scope): short description

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 |
| `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) |

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

### Scope (optional)

Scope goes in parentheses: `fix(deps):`, `feat(saml):`, `ci(release):`. Dependabot is configured to produce `fix(deps):` for npm updates and `chore(ci):` for Actions updates — both fit the changelog config automatically.

### Commits that don't trigger changelog entries

Commits that don't match any of the above types (e.g. `Add something` with no type prefix) are ignored by release-please entirely — no bump, no changelog entry. Always use a type prefix.

---

## Code quality rules

Before any structural refactor on a file >300 LOC, remove dead props, unused exports, and debug logs. Commit separately.
Expand Down
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,19 @@ Every field in every summary panel has a **?** hover tooltip explaining what the

### OIDC Discovery

When working with a JWT, the `/api/discover` endpoint (a Cloudflare Worker) accepts an issuer URL and fetches the corresponding `.well-known/openid-configuration` server-side, avoiding CORS constraints. The route validates that the issuer uses HTTPS before making any outbound request.
When working with a JWT, a **Discover** button appears next to the `iss` claim. This calls the `/api/discover` endpoint (a Cloudflare Worker) which fetches the issuer's `.well-known/openid-configuration` server-side, avoiding CORS constraints. The results panel surfaces the issuer, JWKS URI, auth/token/userinfo endpoints, supported signing algorithms (with a match/mismatch badge against the token's `alg`), and ACR values. The issuer is validated to use HTTPS before any outbound request is made.

---

### Shareable links

A **Copy link** button appears whenever a decode result is displayed. It base64url-encodes the current input into the URL fragment (`#...`) and copies the full URL to the clipboard. Opening that URL pre-populates the input and decodes immediately. The fragment is never sent to any server — the encoding and decoding happen entirely in the browser.

---

### Light / dark mode

A toggle in the header switches between dark mode (default) and light mode. The preference is persisted in `localStorage`.

---

Expand Down Expand Up @@ -138,6 +150,7 @@ src/
cert.ts # X.509 DER/ASN.1 parser: subject, issuer, key algorithm, validity
time.ts # timestamp math and relative label helpers
generic.ts # fallback decoder for unrecognized base64/JSON/XML blobs
hash.ts # base64url encode/decode for shareable URL fragments
explanations.ts # externalized hover tooltip text for all summary fields
InfoTip.svelte # hover tooltip component
routes/
Expand Down Expand Up @@ -169,7 +182,6 @@ Connected to Cloudflare Pages. Every merge to `main` triggers an automatic build
- **SAML error decoder** — human-readable explanations for `<samlp:Status>` codes and `StatusMessage` values ("RequesterError / NoAuthnContext — your IdP couldn't satisfy the requested AuthnContext"); already parsed, just needs a lookup table
- **AuthnContext decoder** — render `<saml:AuthnContextClassRef>` URNs as friendly labels with tooltips covering assurance levels, REFEDS MFA, RAF, and the most common `PasswordProtectedTransport` / `Kerberos` / `TimeSyncToken` values
- **InCommon attribute annotations** — flag attributes in the attribute table as InCommon Baseline Eligible, R&S, or REFEDS RAF/eppn-scoped; your differentiator vs generic SAML tools
- **Shareable links** — encode the pasted payload into the URL fragment (`#`) so it never hits the server; a "Copy link" button lets you pre-load a decoder view to share with colleagues
- **XML syntax highlighting** — color-code element names, attributes, and values in the pretty-printed XML block

### Medium-term
Expand Down
12 changes: 12 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ All SAML and JWT decoding is entirely client-side. Tokens and assertions are nev
3. JWT: base64url-decoded in JavaScript, JSON parsed
4. X.509 certificates embedded in SAML are parsed with a custom pure-JavaScript DER/ASN.1 parser
5. Results are rendered in the DOM — nothing leaves the browser
6. Shareable links encode the paste content into the URL fragment (`#...`) using base64url; fragments are never transmitted to the server by the browser, so shared links carry the same privacy guarantee as direct paste

### What runs server-side

Expand Down Expand Up @@ -94,6 +95,17 @@ The Cloudflare API token stored as a repository secret is scoped to Cloudflare P

The deploy job targets a GitHub Environment named `production`. This provides a configuration point for adding manual approval requirements, allowed-branch restrictions, or deployment protection rules in the future without changing any workflow code.

### HTTP security headers

A `_headers` file at the project root is processed by `@sveltejs/adapter-cloudflare` and deployed to every Cloudflare Pages response:

- `X-Content-Type-Options: nosniff` — prevents MIME-type sniffing
- `X-Frame-Options: DENY` — blocks the site from being embedded in an iframe (clickjacking mitigation)
- `Referrer-Policy: no-referrer` — suppresses the `Referer` header on all outbound navigation; prevents token or payload data from leaking via URL referrers if a user clicks an external link
- `Permissions-Policy` — disables camera, microphone, and geolocation access

The adapter additionally appends `Cache-Control: public, immutable, max-age=31536000` on all `/_app/immutable/*` assets and `Cache-Control: no-cache` on mutable app assets.

### Dependency auditing

The CI workflow runs `npm audit --audit-level=high` on every push and pull request. The build fails if any high or critical vulnerabilities are present in the transitive dependency tree.
Expand Down
5 changes: 5 additions & 0 deletions _headers
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/*
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: no-referrer
Permissions-Policy: camera=(), microphone=(), geolocation=()
3 changes: 1 addition & 2 deletions release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@
"package-name": "samlguy",
"changelog-sections": [
{ "type": "feat", "section": "Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "fix", "section": "Bug Fixes & Dependencies" },
{ "type": "perf", "section": "Performance" },
{ "type": "security", "section": "Security" },
{ "type": "deps", "section": "Dependencies" },
{ "type": "docs", "section": "Documentation", "hidden": false },
{ "type": "chore", "section": "Miscellaneous", "hidden": true },
{ "type": "ci", "section": "CI/CD", "hidden": true }
Expand Down
18 changes: 14 additions & 4 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,15 @@
}
}

function safeHref(url: string | null): string | null {
if (!url) return null;
try {
return new URL(url).protocol === 'https:' ? url : null;
} catch {
return null;
}
}

const ENCODING_LABELS: Record<string, string> = {
'base64+deflate': 'base64 + DEFLATE',
base64: 'base64',
Expand All @@ -260,6 +269,7 @@
</div>

<textarea
id="input"
bind:value={input}
placeholder="Paste anything — SAMLRequest, SAMLResponse, JWT, Authorization header, query string, full URL..."
class="h-48 w-full resize-y rounded-lg border border-neutral-300 bg-neutral-50 px-4 py-3 font-mono text-sm text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:placeholder-neutral-600"
Expand Down Expand Up @@ -512,10 +522,10 @@

<!-- Discovery results -->
{#if discoveryResult}
{@const jwksUri = typeof discoveryResult.jwks_uri === 'string' ? discoveryResult.jwks_uri : null}
{@const authEp = typeof discoveryResult.authorization_endpoint === 'string' ? discoveryResult.authorization_endpoint : null}
{@const tokenEp = typeof discoveryResult.token_endpoint === 'string' ? discoveryResult.token_endpoint : null}
{@const userinfoEp = typeof discoveryResult.userinfo_endpoint === 'string' ? discoveryResult.userinfo_endpoint : null}
{@const jwksUri = safeHref(typeof discoveryResult.jwks_uri === 'string' ? discoveryResult.jwks_uri : null)}
{@const authEp = safeHref(typeof discoveryResult.authorization_endpoint === 'string' ? discoveryResult.authorization_endpoint : null)}
{@const tokenEp = safeHref(typeof discoveryResult.token_endpoint === 'string' ? discoveryResult.token_endpoint : null)}
{@const userinfoEp = safeHref(typeof discoveryResult.userinfo_endpoint === 'string' ? discoveryResult.userinfo_endpoint : null)}
{@const supportedAlgs = Array.isArray(discoveryResult.id_token_signing_alg_values_supported) ? discoveryResult.id_token_signing_alg_values_supported as string[] : null}
{@const acrValues = Array.isArray(discoveryResult.acr_values_supported) ? discoveryResult.acr_values_supported as string[] : null}
<div
Expand Down
2 changes: 1 addition & 1 deletion src/routes/api/discover/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const GET: RequestHandler = async ({ url }) => {
error(400, 'issuer must use HTTPS');
}

const discoveryUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
const discoveryUrl = `${issuerUrl.href.replace(/\/$/, '')}/.well-known/openid-configuration`;

const response = await fetch(discoveryUrl, {
headers: { Accept: 'application/json' }
Expand Down
5 changes: 4 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@ import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';

export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
plugins: [tailwindcss(), sveltekit()],
build: {
cssCodeSplit: false
}
});
Loading