blog.krauza.com
~ / posts / pocket_id_adfs_integration / index.md

Adding Passkey Authentication to ADFS with PocketID

ADFS supports OIDC for downstream apps but only speaks SAML 2.0 for incoming federation. PocketID only speaks OIDC. So we extended PocketID’s Go backend with a SAML IdP surface and wired it into ADFS as a Claims Provider Trust.

Running ADFS in a homelab puts you in an interesting position: you have a real, enterprise-grade federation service sitting in your basement, and increasingly the rest of the self-hosted ecosystem has moved on to OIDC-first tooling that treats ADFS as a legacy curiosity. For a long time that wasn’t a problem. ADFS does OIDC just fine for outbound tokens, and most of the apps I care about (Concourse, Grafana, GitLab) can speak OIDC to it without complaint. But passkey authentication is another story. Every guide either assumes you’re migrating to Keycloak or Authentik, or it assumes you don’t have ADFS at all. Neither was an option I wanted to pursue: my entire identity stack (users, groups, permissions, service accounts) lives in Active Directory, and rearchitecting that just to get passkeys felt like the wrong tradeoff.

PocketID is a self-hosted OIDC identity provider with a single-minded focus on passkey authentication. It is not trying to be Keycloak. It is not trying to replace your directory service. It authenticates users via WebAuthn, issues OIDC tokens, and stays out of your way. That was exactly 80% of what I wanted, but the remaining 20% was a hard incompatibility. PocketID speaks OIDC. ADFS’s incoming federation interface, the Claims Provider Trust, only speaks SAML 2.0. There is no workaround for this; it is a hard constraint in how ADFS is built. So rather than migrate, I extended PocketID.

This post covers what that extension looks like and how to wire it up. The troubleshooting journey (and there was a significant one) is a separate post, “Eight Circles of ADFS Debugging: Adding PocketID as a ADFS Claims Provider”.

The Three Protocols in This Stack

Before getting into the implementation, it’s worth being precise about the three protocols involved, because each one sits at a different layer of this federation chain and they interact in ways that aren’t obvious at first glance.

OIDC: The Protocol PocketID Already Speaks

OpenID Connect is the protocol that powers “Sign in with Google”, an identity layer built on top of OAuth 2.0 that lets an identity provider issue tokens to downstream applications. The flow is straightforward: your application redirects the user to the IdP, the IdP authenticates the user and issues a signed JWT (an ID token), and the application verifies the token and considers the user logged in. PocketID implements this as the IdP side; it is, essentially, your own personal “Sign in with Google” server, except the authentication mechanism is a passkey instead of a password.

ADFS also supports OIDC, but only on the outbound side (toward downstream applications). When Concourse or Grafana authenticates a user via ADFS, they’re using OIDC. That part works fine and doesn’t change in this setup.

WS-Federation: The Road Not Taken

My first attempt at this was a WS-Federation proxy. WS-Fed is Microsoft’s older federation protocol, predating SAML 2.0 in widespread enterprise use and what ADFS was originally built around (SAML support came later). Like SAML, it exchanges XML-based security tokens, but the structure is different: WS-Fed uses WS-Trust and WS-Security specifications underneath, and the token format is flexible. A WS-Fed envelope can wrap either a SAML 1.1 token or a SAML 2.0 token, which makes the spec surface area considerably larger than it initially appears.

The Go ecosystem for WS-Federation is essentially nonexistent. There is no maintained WS-Fed library in Go the way there is for SAML or OIDC. What exists is mostly partial, unmaintained, or specific to a particular deployment. I wrote a proxy with Claude, the code exists, and it works up to a point, but the depth of the WS-Fed spec, combined with the absence of good tooling, made it impractical to get it fully operational. SAML 2.0 was the right pivot, both because the tooling exists and because ADFS’s SAML CPT support is more straightforward to configure than its WS-Fed equivalent.

SAML 2.0: What We Actually Built

SAML 2.0 (Security Assertion Markup Language) is an XML-based protocol for exchanging authentication and authorization data between an Identity Provider and a Service Provider. If you’ve worked with OIDC, the mental model is similar, but the mechanics are different in ways that matter for implementation.

The central artifact is the SAML Assertion, an XML document signed by the IdP with its private key that makes claims about the user. A typical assertion contains a NameID (the user’s identity in the context of this federation), a set of attributes (email address, user principal name, display name), and metadata like when the assertion was issued and when it expires. The SP validates the assertion’s signature against the IdP’s public key (obtained from the IdP’s metadata document), and if the signature is valid, trusts the claims inside.

SAML defines multiple ways to transport these assertions between parties, called bindings. Two matter for this implementation:

  • HTTP-Redirect binding: The SP encodes an AuthnRequest (a request for the IdP to authenticate a user) into a URL query parameter, deflates and base64-encodes it, and sends the user’s browser there via a 302 redirect. This is the typical way a Service Provider initiates authentication. ADFS uses this to ask PocketID to authenticate the user.
  • HTTP-POST binding: The IdP returns the signed assertion by generating an HTML form with the base64-encoded SAMLResponse as a hidden input field and auto-submitting it via JavaScript. This is used for the response rather than the request because assertions are larger than what fits cleanly in a URL. PocketID uses this to return the signed assertion back to ADFS.

The IdP publishes a metadata document at a well-known URL: an XML file that describes its entity ID, certificate, and SSO endpoint. ADFS imports this metadata when you add a Claims Provider Trust, which is how it learns where to send users and how to verify assertion signatures.

How ADFS Federates Identity

ADFS is a broker, not a directory. It does not store users; it issues tokens by sitting between applications and the places users actually exist. The configuration model has two sides:

Relying Party Trusts are the outbound side: the applications ADFS issues tokens to. Concourse, Grafana, GitLab, AWS: each of these has an RPT in ADFS, and when a user authenticates, ADFS issues a token (OIDC, SAML, or WS-Fed depending on what the application requested) to that party.

Claims Provider Trusts are the inbound side: the identity sources ADFS accepts authentication assertions from. Every ADFS deployment has exactly one CPT by default: Active Directory itself. That trust is automatic and cannot be removed. Adding additional CPTs lets ADFS delegate authentication outward to external identity providers. And here is the constraint that drove this whole project: ADFS supports OIDC for Relying Party Trusts, but for Claims Provider Trusts it only speaks SAML 2.0. There is no OIDC CPT support. If you want an external IdP to hand identity to ADFS, it must do so via SAML.

flowchart LR
    subgraph cpt [Claims Provider Trusts inbound]
        AD[Active Directory\ndefault CPT]
        PID[PocketID\nSAML CPT]
    end

    subgraph rpt [Relying Party Trusts outbound]
        Con[Concourse\nOIDC]
        Graf[Grafana\nOIDC]
        GL[GitLab\nOIDC]
    end

    AD -->|Kerberos / LDAP| ADFS
    PID -->|SAML 2.0 assertion| ADFS
    ADFS -->|OIDC token| Con
    ADFS -->|OIDC token| Graf
    ADFS -->|OIDC token| GL

The full federation chain in my setup looks like this:

sequenceDiagram
    actor U as User
    participant App as Concourse
    participant ADFS as ADFS
    participant PID as PocketID
    participant AD as Active Directory

    U->>App: Access application
    App->>ADFS: Redirect (OIDC authorization request)
    ADFS->>U: Home Realm Discovery, PocketID selected
    U->>PID: SAMLRequest via HTTP-Redirect binding
    PID->>U: Passkey challenge
    U->>PID: Passkey response
    PID->>ADFS: SAMLResponse via HTTP-POST, RSA-SHA256 signed
    ADFS->>AD: LDAP lookup, UPN to sAMAccountName and group SIDs
    AD->>ADFS: User attributes + group membership
    ADFS->>App: OIDC ID token
    App->>U: Access granted

ADFS is the translation layer: SAML in from PocketID, OIDC out to the application. The passkey UX is entirely inside PocketID. The group membership and permissions are entirely inside Active Directory. ADFS brokers between them.

Extending PocketID

PocketID is a Go backend (Gin framework) with a SvelteKit frontend. The extension adds a SAML IdP surface to the Go side with three constraints: no changes to the existing OIDC engine, no changes to the passkey UX, and no new external dependencies like Redis or a separate database. The SAML layer is a pure protocol adapter.

I should be upfront about something: I am not a Go developer. I used Claude to write the Go code. And honestly, that turned out to be the easy part. Claude understood the PocketID codebase, picked the right library, designed a reasonable session store, and wired everything together correctly on the first attempt. The hard part was everything outside the code: figuring out why ADFS was rejecting valid assertions, discovering that Access Control Policies don’t work for federated identities, tracking down the Firefox CSP behavior that Chrome silently ignored. Those failure modes are not in any tutorial. The Go was a solved problem the moment I knew what to build. Getting ADFS to actually trust the result was a different kind of work entirely, and that’s what the follow-up post covers.

The Route Surface

Four new routes handle the entire SAML surface:

MethodPathPurpose
GET/saml/metadataReturns the SAML metadata XML that ADFS imports
GET/saml/ssoReceives SAML AuthnRequests via HTTP-Redirect binding
POST/saml/ssoReceives SAML AuthnRequests via HTTP-POST binding
GET/saml/callbackPost-authentication callback that builds and returns the SAMLResponse

The /saml group is registered before the SvelteKit frontend catch-all route in Gin’s router, because Gin uses first-match routing and the SPA catch-all would otherwise intercept /saml/callback before the backend handler sees it.

The Request Flow

The full flow, from the moment ADFS redirects the user to PocketID through the moment PocketID hands the assertion back:

  1. ADFS sends the user to /saml/sso with a base64+deflate-encoded SAMLRequest in the query string (HTTP-Redirect binding). The handler parses the AuthnRequest, validates the issuer against SAML_SP_ENTITY_ID, and validates the ACS URL against SAML_ACS_URL; the ACS URL is never taken from the AuthnRequest itself, only used to confirm the request is from the expected SP.

  2. A 256-bit nonce is generated and a PendingSession is stored in memory, keyed on that nonce. The session holds the AuthnRequest ID, RelayState, and the raw decompressed AuthnRequest XML. A saml_pending cookie is set in the response. The user is redirected to /login?redirect=/saml/callback.

  3. The user authenticates via passkey. This is entirely unchanged from PocketID’s normal OIDC flow: the same login page, the same WebAuthn ceremony, the same JWT access token cookie.

  4. After a successful passkey authentication, the frontend navigates to /saml/callback using window.location.href rather than SvelteKit’s goto(). The callback is a backend-rendered HTML page, not a SvelteKit route, so goto() does not work here.

  5. /saml/callback reads the saml_pending cookie to get the nonce, looks up the PendingSession, verifies the JWT access token cookie to get the authenticated user, loads the user record, and builds the SAMLResponse. The nonce is deleted from the store before the response is written, making it single-use with no replay.

  6. The handler returns an HTML page containing a form with the base64-encoded SAMLResponse as a hidden field, pointing at the ADFS ACS URL. A nonce-tagged inline script auto-submits the form. The browser POSTs to ADFS, ADFS validates the signature, extracts the claims, and issues its own token to the downstream application.

flowchart TD
    A[ADFS sends SAMLRequest\nHTTP-Redirect to /saml/sso] --> B[Validate AuthnRequest\ncheck issuer and ACS URL]
    B --> C[Generate nonce\nStore PendingSession\nSet saml_pending cookie]
    C --> D[302 to /login with redirect param]
    D --> E[Passkey authentication\nunchanged UX]
    E --> F[JWT access_token cookie set\nnavigate to /saml/callback]
    F --> G[saml/callback\nRead cookie, look up nonce\nVerify JWT, load user]
    G --> H[Build and sign SAMLResponse\nRSA-SHA256\nDelete nonce]
    H --> I[Autosubmit form POST\nto ADFS ACS URL]
    I --> J[ADFS validates signature\nextracts claims\nissues OIDC token to app]

The Go Code

All new Go code lives in a single package: backend/internal/saml/. Five files with narrow responsibilities: config.go validates the SAML_* environment variables at startup and exits immediately if anything is missing or inaccessible; session.go implements the bounded in-memory pending session store; idp.go constructs the crewjam IdentityProvider from config; service.go handles AuthnRequest validation and SAMLResponse construction; and handler.go contains the Gin HTTP controllers for the four routes.

One design detail in session.go is worth calling out. The PendingSession struct stores the raw decompressed AuthnRequest XML bytes rather than just the parsed fields:

go
1type PendingSession struct {
2    AuthnRequestID string
3    RelayState     string
4    RequestBuffer  []byte    // raw decompressed AuthnRequest XML
5    CreatedAt      time.Time
6}

The reason is that crewjam/saml re-parses the original request when building the response, and by the time the user finishes passkey authentication, the IssueInstant timestamp in the XML will have expired. Storing the raw bytes lets the callback reconstruct the request object without re-running timestamp validation, which would always fail.

Beyond the new package, the existing codebase needed a handful of targeted changes. Some were mechanical: new SAML_* fields on the central config struct, a SAMLPendingCookieName cookie constant, conditional service wiring in the bootstrap layer. A few were less obvious.

Changes to Existing Code

The Gin router required the /saml route group to be registered before frontend.RegisterFrontend(). Gin uses first-match routing, and the SvelteKit SPA catch-all registered by that call would intercept any request to /saml/callback before the backend handler could see it. One line in the wrong order and every callback request silently serves the frontend’s 404 page.

The SvelteKit side required more investigation. PocketID’s login page uses SvelteKit’s goto() for post-authentication navigation, which does client-side routing. That works fine for SvelteKit routes, but /saml/callback is a backend-rendered Gin handler, not a SvelteKit page. goto() never hits the server; it just updates the browser URL and renders nothing useful. Switching to window.location.href forces a full navigation, which actually reaches the backend.

The trickier problem was the SvelteKit layout guard in redirection-util.ts. PocketID has a standard pattern: if you’re already authenticated and you hit /login, you get redirected to /settings. That works perfectly for normal use. But with SAML, the flow is: ADFS sends you to PocketID’s login page with ?redirect=/saml/callback in the URL. If you’re already logged in, the layout guard fires, ignores the redirect param, and sends you to /settings instead of completing the SAML handshake. The fix is a check before the guard redirects:

typescript
1if (isUnauthenticatedOnlyPath && isSignedIn) {
2    const redirectParam = url.searchParams.get('redirect');
3    if (redirectParam?.startsWith('/saml/')) {
4        return redirectParam;
5    }
6    // ...normal redirect to /settings
7}

That fixed authenticated users. But it introduced a second problem. ADFS will sometimes navigate back to /saml/callback after a completed flow or after an error, with no saml_pending cookie. In that state, the callback handler has nothing to work with, so it redirects to /login?redirect=/saml/callback. The layout guard sees an authenticated user with a /saml/ redirect param and sends them back to /saml/callback. Loop.

The fix required two coordinated changes. In the Go callback handler, detect the “authenticated but no pending session” case and redirect to /login?error=... rather than /login?redirect=/saml/callback. In redirection-util.ts, suppress the layout guard entirely when an error param is present:

typescript
1if (url.searchParams.get('error')) {
2    return null; // let the page render with the error message
3}

Neither change is complicated in isolation, but the interaction between the Go handler and the SvelteKit guard wasn’t obvious until the loop actually happened in the browser. That’s representative of the whole class of problems with this kind of integration work: the pieces individually make sense, but the boundaries between them produce behavior you only see when everything is running end-to-end.

The SAML Library Choice

The implementation uses crewjam/saml v0.5.1. XML canonicalization (required for correct enveloped XML signatures) and the precise placement of the <ds:Signature> element within the assertion are subtle enough that hand-rolling them is a significant risk. The crewjam library handles both correctly.

One non-default setting was required: crewjam/saml defaults to RSA-SHA1 for assertion signatures. ADFS rejects RSA-SHA1 with event ID MSIS3017, citing a signature algorithm mismatch. Setting SignatureMethod: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" on the IdentityProvider struct resolves this. It is easy to miss because RSA-SHA1 is technically valid SAML, but ADFS won’t accept it.

Pending Session Storage

SAML authentication is stateful in a specific way: the AuthnRequest arrives at /saml/sso, but the SAMLResponse isn’t built until /saml/callback, after the user has authenticated. Something has to hold the pending state between those two requests.

The implementation uses a bounded in-memory map with a 10-minute TTL and a maximum of 1000 concurrent entries. A background goroutine sweeps expired entries every 60 seconds. The tradeoff is that a process restart during an active authentication flow loses the pending session, requiring the user to restart from their application. For a homelab, that is a fine tradeoff to avoid adding a Redis dependency.

The 1000-entry cap is a DoS consideration: unauthenticated POST requests to /saml/sso are the attack surface. Without the cap, an adversary could flood the endpoint and exhaust memory. At 1000, the store returns a 400 until the cleanup goroutine reclaims space.

Claim Mapping

The assertion carries four pieces of identity information that ADFS needs:

PocketID FieldSAML Attribute
user.Username (qualified with SAML_UPN_DOMAIN if bare)NameID (urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress)
user.Emailhttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress
user.Usernamehttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn
user.FirstName + " " + user.LastNamehttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/name

The NameID is the identity anchor: the value ADFS uses to link the incoming assertion to a user in its own context. Setting it to the UPN (qualified with the AD domain suffix if needed) is what allows ADFS to subsequently query Active Directory and enrich the token with group membership. Setting it to PocketID’s internal GUID (which was the default behavior before this was explicitly configured) produced a UserId that ADFS didn’t know what to do with.

The SAML_UPN_DOMAIN variable handles the case where PocketID stores usernames without a domain suffix (e.g., akrauza) but ADFS and Active Directory expect UPN format (akrauza@ad.example.com). When set, the handler appends @domain to any bare username that doesn’t already contain an @ character.

The CSP Problem

PocketID’s global CSP middleware sets form-action 'self' on every response. The autosubmit form on /saml/callback needs to POST to the ADFS ACS URL, which is not 'self', so that global policy blocks it. The callback handler overrides the Content-Security-Policy header after the middleware sets it, widening form-action to *.

The extended form-action is actually necessary beyond just the ADFS ACS URL: ADFS processes the SAMLResponse and then 302-redirects the browser to the downstream application’s callback URL (e.g., https://concourse.example.com/sky/issuer/callback). Firefox, correctly per spec, enforces form-action on redirect destinations as well as the initial POST target. That downstream URL cannot be predicted at config time because it depends on which application initiated the ADFS flow. Firefox surfaced this; Chrome did not. The form-action * is the correct fix.

The real protection here is not the CSP restriction; it is the RSA-SHA256 signature on the assertion. ADFS validates the signature against the IdP certificate from the metadata document and rejects any response that doesn’t pass. Widening form-action on a page that contains only a server-signed assertion has no meaningful security impact.

Environment Variables

The full set of configuration variables for the SAML extension:

VariableRequiredExampleDescription
SAML_ENABLEDtrueMaster switch; all other variables are ignored when false
SAML_ENTITY_IDYeshttps://pocketid.example.com/saml/metadataIdP entity ID; also the URL where the metadata document is served
SAML_CERT_FILEYes/etc/pocketid/saml.crtPath to PEM-encoded X.509 certificate
SAML_KEY_FILEYes/etc/pocketid/saml.keyPath to PEM-encoded RSA private key
SAML_ACS_URLYeshttps://adfs.example.com/adfs/ls/ADFS Assertion Consumer Service URL; validated against every incoming AuthnRequest
SAML_SP_ENTITY_IDYeshttps://adfs.example.com/adfs/services/trustADFS federation service identifier; validated against every incoming AuthnRequest issuer
SAML_UPN_DOMAINNoad.example.comAppended as @domain to bare usernames in the UPN and NameID claims

Generate the key pair before configuring anything else:

bash
1openssl req -x509 -newkey rsa:2048 -keyout saml.key -out saml.crt -days 3650 -nodes \
2  -subj "/CN=pocketid-saml"

Configuring ADFS

Once PocketID is running with the SAML extension enabled, ADFS needs to be told about it.

Adding the Claims Provider Trust

Open AD FS Management → Claims Provider Trusts → Add Claims Provider Trust. Choose “Import data about the claims provider published online or on a local network” and enter the metadata URL: https://pocketid.example.com/saml/metadata. The wizard reads the entity ID, certificate, and SSO endpoint from the metadata document automatically.

After the wizard completes, the CPT exists but has no Acceptance Transform Rules, which means ADFS will accept the assertion but pass nothing through to the downstream token. The rules are the next step.

Acceptance Transform Rules

Claims don’t flow automatically once the CPT exists. Each claim PocketID sends must be explicitly accepted and passed through. After those Acceptance Transform Rules run, ADFS can optionally query Active Directory to enrich the token before it reaches the downstream application.

flowchart LR
    PID[PocketID assertion\nNameID UPN\nEmail\nName] --> AT[CPT Acceptance\nTransform Rules\npass-through]
    AT --> ADFS{ADFS\nclaims pipeline}
    ADFS --> LDAP[AD Attribute Store\nLDAP lookup\nUPN to groups and SIDs]
    LDAP --> IT[RPT Issuance\nTransform Rules]
    IT --> APP[OIDC ID Token\nto Concourse / Grafana]

Edit the claim rules on the new CPT and add pass-through rules for the three claims PocketID sends:

  • Pass Through: Email Address (http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress)
  • Pass Through: UPN (http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn)
  • Pass Through: Name (http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name)

If you need ADFS to enrich the token with Active Directory group membership (for LDAP attribute store queries downstream), add an additional rule that queries AD using the incoming UPN:

text
1c:[Type == "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn",
2   Value =~ "^(?i)(.+)@ad\.example\.com$"]
3 => issue(store = "Active Directory",
4          types = ("http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname"),
5          query = "userPrincipalName={0};sAMAccountName;YOURDOMAIN\ignored",
6          param = c.Value);

A few things about this rule that are not obvious. First, the YOURDOMAIN\ignored at the end is the credential parameter; it identifies the domain controller to query, but the username portion is completely ignored by ADFS. Using a placeholder like ignored is not a hack; it is documented behavior. Second, querying the AD attribute store directly like this causes ADFS to emit the resulting claim with AD AUTHORITY as the issuer, which is necessary for downstream RP authorization rules that gate on issuer. Constructing the claim manually and trying to spoof that issuer does not work reliably.

The windowsaccountname claim itself is what makes the rest of the group authorization pipeline work. Once ADFS has a DOMAIN\username value in that claim type, the AD attribute store can resolve it to group SIDs via a second lookup. Without windowsaccountname in the token, ADFS has no DOMAIN\username identity to query against, and any downstream rule that tries to gate access on group membership will silently fail to match. The UPN claim alone is not sufficient; the AD attribute store’s group SID resolution is keyed on the Windows account name format, not UPN.

AnchorClaimType

For OIDC flows (which is how most downstream applications talk to ADFS), ADFS needs to know which incoming claim represents the user’s unique identity for building the ID token. Without this, ADFS cannot construct the token and the OIDC flow fails with event ID MSIS9642.

Set it via PowerShell:

powershell
1Set-AdfsClaimsProviderTrust -TargetName "PocketID" `
2    -AnchorClaimType "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"

Authorization on Downstream Relying Parties

One important finding from the troubleshooting work: Access Control Policies in ADFS cannot authorize federated identities. Access Control Policies resolve group membership via Windows token and Kerberos, a mechanism that only works for direct Active Directory authentication. Federated users, regardless of what claims they carry, cannot satisfy an Access Control Policy group check. This is not a misconfiguration; it is by design.

For any Relying Party Trust that needs to support both direct AD users and PocketID users, replace Access Control Policy group checks with Issuance Authorization Rules using group SIDs:

powershell
1$sid = (Get-ADGroup -Identity "Grafana Users").SID.Value
2$rule = "c:[Type == `"http://schemas.microsoft.com/ws/2008/06/identity/claims/groupsid`", Value == `"$sid`"]
3 => issue(Type = `"http://schemas.microsoft.com/authorization/claims/permit`", Value = `"true`");"
4
5Set-AdfsWebApiApplication -TargetName "Grafana - Web API" `
6    -AccessControlPolicyName "" `
7    -IssuanceAuthorizationRules $rule

Using group SIDs rather than group names is also the right practice regardless: SIDs are stable identifiers that don’t change if the group is renamed.

The Point of All This

What this extension provides is passkey authentication for every application in my ADFS estate, without migrating away from Active Directory as the authoritative user store. PocketID handles the WebAuthn ceremony. ADFS handles the translation to OIDC (or SAML, or WS-Fed) for downstream applications. Active Directory continues to hold users, groups, and permissions. Each piece does what it was designed for.

The Go code touches nothing in PocketID’s OIDC engine and nothing in the passkey UX. Someone authenticating through this flow sees exactly the same login experience as before: they tap a hardware key or use platform biometrics, and the SAML protocol exchange is invisible to them. The extension is, in the end, a protocol adapter: it speaks SAML to ADFS and speaks OIDC and WebAuthn internally, and the seam between the two is four HTTP routes and a bounded in-memory session map.

There are follow-up posts worth writing about the troubleshooting path, covering the eight distinct failure modes we worked through: the Access Control Policy limitation, the identity linking problem with NameIDs, and the Firefox CSP behavior that Chrome silently accepted. Those are for a separate post. This one is the part that actually works.

Search