epds-login

Implementing ePDS Login

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "epds-login" with this command: npx skills add hypercerts-org/epds/hypercerts-org-epds-epds-login

Implementing ePDS Login

ePDS lets your users sign in to AT Protocol apps — like Bluesky — using familiar login methods: email OTP, Google, GitHub, or any other provider Better Auth supports. Under the hood it is a standard AT Protocol PDS wrapped with a pluggable authentication layer. Users just sign in with their email or social account and get a presence in the AT Protocol universe (a DID, a handle, a data repository) automatically provisioned.

From your app's perspective, ePDS uses standard AT Protocol OAuth (PAR + PKCE + DPoP). The reference implementation is packages/demo in the ePDS repository.

Two Flows

Flow 1 Flow 2

App collects email? Yes No

PAR includes Nothing extra Nothing extra

Auth server shows OTP input directly Email form first

Redirect includes &login_hint=<email>

Nothing extra

Important: login_hint must never go in the PAR body when the value is an email address. The PDS core (AT Protocol layer) validates login_hint as an ATProto identity (handle like user.bsky.social or DID like did:plc:… ) and rejects email addresses with Invalid login_hint . Put login_hint only on the auth redirect URL — that request goes to the ePDS auth service (Better Auth layer), which accepts emails and uses them to skip the email-collection step.

Quick Start

  1. Client Metadata

Host at your client_id URL (must be HTTPS in production):

{ "client_id": "https://yourapp.example.com/client-metadata.json", "client_name": "Your App", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], "scope": "atproto transition:generic", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none", "dpop_bound_access_tokens": true }

Optional branding fields: logo_uri , email_template_uri , email_subject_template , brand_color , background_color .

  1. Login Handler

// GET /api/oauth/login?email=user@example.com (Flow 1) // GET /api/oauth/login (Flow 2)

const { privateKey, publicJwk, privateJwk } = generateDpopKeyPair() const codeVerifier = generateCodeVerifier() const codeChallenge = generateCodeChallenge(codeVerifier) const state = generateState()

const parBody = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'atproto transition:generic', state, code_challenge: codeChallenge, code_challenge_method: 'S256', })

// PAR always requires a DPoP nonce retry — handle it: let parRes = await fetch(PAR_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: createDpopProof({ privateKey, jwk: publicJwk, method: 'POST', url: PAR_ENDPOINT, }), }, body: parBody.toString(), }) if (!parRes.ok) { const nonce = parRes.headers.get('dpop-nonce') if (nonce && parRes.status === 400) { parRes = await fetch(PAR_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: createDpopProof({ privateKey, jwk: publicJwk, method: 'POST', url: PAR_ENDPOINT, nonce, }), }, body: parBody.toString(), }) } }

const { request_uri } = await parRes.json()

// Save state in signed HttpOnly cookie (maxAge: 600 to match request_uri lifetime) const loginHintParam = email ? &#x26;login_hint=${encodeURIComponent(email)} : '' const authUrl = ${AUTH_ENDPOINT}?client_id=${encodeURIComponent(clientId)}&#x26;request_uri=${encodeURIComponent(request_uri)}${loginHintParam} // redirect to authUrl

  1. Callback Handler

// GET /api/oauth/callback?code=...&state=...

const { codeVerifier, dpopPrivateJwk, state: savedState, } = getSessionFromCookie() if (params.state !== savedState) throw new Error('state mismatch')

const { privateKey, publicJwk } = restoreDpopKeyPair(dpopPrivateJwk)

// Token exchange — also requires DPoP nonce retry: let tokenRes = await fetch(TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: createDpopProof({ privateKey, jwk: publicJwk, method: 'POST', url: TOKEN_ENDPOINT, }), }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: clientId, code_verifier: codeVerifier, }).toString(), }) if (!tokenRes.ok) { const nonce = tokenRes.headers.get('dpop-nonce') if (nonce) { tokenRes = await fetch(TOKEN_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', DPoP: createDpopProof({ privateKey, jwk: publicJwk, method: 'POST', url: TOKEN_ENDPOINT, nonce, }), }, body: /* same body */ '', }) } }

const { sub: userDid } = await tokenRes.json() // sub is a DID e.g. "did:plc:abc123..." — resolve to handle via PLC directory

Common Pitfalls

Pitfall Fix

Flash of email form Include login_hint on the auth redirect URL only (never in the PAR body)

Invalid login_hint from PAR Remove login_hint from the PAR body — PDS core only accepts ATProto handles/DIDs, not emails

auth_failed immediately Check Caddy logs — likely a DNS/upstream name mismatch

DPoP rejected Always implement the nonce retry loop (ePDS always demands a nonce)

Cannot find package in tests Run pnpm build before pnpm test — vitest needs dist/

Token exchange fails Restore the DPoP key pair from the session cookie, don't generate a new one

Double OTP email Normal on duplicate GET — otpAlreadySent flag suppresses auto-send on reload

Handles

ePDS generates random handles, not email-derived ones. When a user signs up with alice@example.com , their handle will be something like a3x9kf.pds.example

(random prefix + PDS hostname), not alice.pds.example . Resolve the handle from the DID via the PLC directory after login (shown in the callback handler).

ePDS Endpoints (defaults)

PAR: https://<pds-hostname>/oauth/par Auth: https://auth.&#x3C;pds-hostname>/oauth/authorize Token: https://<pds-hostname>/oauth/token

Reference Files

  • PKCE and DPoP helpers — full TypeScript implementations

  • Client metadata fields — all supported fields including email branding

  • Full flow walkthrough — sequence diagrams and step-by-step for both flows

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

PanchangaAPI — Vedic Astrology

Vedic astrology (Jyotish) REST API powered by Swiss Ephemeris. 24 endpoints: Panchanga, Kundali (300+ Yogas, Ashtakavarga, Doshas), KP system (249 sub-lords)...

Registry SourceRecently Updated
General

OPC Invoice Manager

Accounts Receivable light system for solo entrepreneurs. Manages the full billing lifecycle: invoice generation, collections follow-up, payment reconciliatio...

Registry SourceRecently Updated
General

NBA Tracker

提供NBA球队和球员赛程、实时比分及关键时刻提醒,支持追踪球员伤病和自动添加比赛到日历,适合观赛辅助。

Registry SourceRecently Updated
General

Okr Planner

OKR目标管理。OKR制定、季度复盘、上下对齐、评分、模板库、级联分解。OKR planner with goal setting, quarterly reviews, alignment, scoring, templates, cascading. OKR、目标管理、绩效。

Registry SourceRecently Updated