A standalone Solid-OIDC v0.1.0-conformant Identity Provider deployed on Netlify Functions, where ORCID is the upstream identity source.
Anyone with an ORCID iD obtains a DPoP-bound ID Token whose webid claim resolves to a profile served by the IDP itself at ${BASE_URL}/profile/${orcid}#me. The profile declares ${BASE_URL} as the authorized OIDC issuer, satisfying the Solid-OIDC issuer-discovery check.
- Node.js 18+
- netlify-cli for local dev (
npm install -g netlify-cli) - ORCID sandbox credentials from https://sandbox.orcid.org/developer-tools
- Register at https://sandbox.orcid.org/register (use a
@mailinator.comemail). - Go to https://sandbox.orcid.org/developer-tools.
- Register a new Public API client:
- Name: anything (e.g.
orcid-idp-netlify) - Redirect URI:
http://127.0.0.1:8888/orcid/cb(for local dev; change per environment)
- Name: anything (e.g.
- Save your
client_idandclient_secret.
npm install
# Generate the JWKS and openid-configuration in public/
DEPLOY_URL=http://127.0.0.1:8888 \
ORCID_ISSUER=https://sandbox.orcid.org \
ORCID_CLIENT_ID=APP-XXXX \
ORCID_CLIENT_SECRET=xxxxxxxx \
node scripts/generate-identity.ts
# Run netlify dev (binds functions to port 8888)
IDP_ISSUER=http://127.0.0.1:8888 \
ORCID_ISSUER=https://sandbox.orcid.org \
ORCID_CLIENT_ID=APP-XXXX \
ORCID_CLIENT_SECRET=xxxxxxxx \
ORCID_REDIRECT_URI=http://127.0.0.1:8888/orcid/cb \
npm run devBuild-time generates public/jwks.json, public/.well-known/openid-configuration. Runtime functions serve /.well-known/webfinger, /authorize, /orcid/cb, /token, /register, /profile/:orcid.
| Variable | Required | Description |
|---|---|---|
ORCID_CLIENT_ID |
yes | From ORCID developer tools |
ORCID_CLIENT_SECRET |
yes | From ORCID developer tools |
ORCID_ISSUER |
no | https://orcid.org (prod) / https://sandbox.orcid.org (sandbox). Default: prod. |
IDP_ISSUER |
runtime | Issuer URL advertised in iss and JWKS. Defaults to the deploy URL (URL / DEPLOY_URL at build time). Set only when the issuer must differ from the deployed hostname (e.g., reverse proxy). |
ORCID_REDIRECT_URI |
runtime | The IDP's ORCID callback. Default: ${IDP_ISSUER}/orcid/cb |
WEBID_TTL_SECONDS |
runtime | ID-token lifetime. Default: 3600 |
CLIENT_ID_DOC_TTL_SECONDS |
runtime | Cache lifetime for external Client ID Documents fetched for static/dereferenceable client_ids (see Static clients). Default: 3600 |
JWKS |
build | Reuse an existing JWKS JSON across deploys |
URL / DEPLOY_URL |
build | Set automatically by Netlify; used to populate iss and JWKS host |
| Route | Method | Purpose |
|---|---|---|
/.well-known/openid-configuration |
GET | Static (built). Solid-OIDC discovery. |
/.well-known/openid-configuration (runtime) |
GET | Same shape, served dynamically by openid-configuration.mts |
/jwks.json |
GET | Static (built). ES256 public key. |
/.well-known/webfinger |
GET | ?resource=acct:{orcid}@{host} or full WebID → JRD. |
/profile/{orcid} |
GET | Turtle profile declaring solid:oidcIssuer. |
/authorize |
GET | PKCE-protected; redirects to ORCID. |
/orcid/cb |
GET | ORCID callback; mints an internal code and redirects to the client's redirect_uri. |
/token |
POST | authorization_code and refresh_token grants; DPoP-bound Solid-OIDC ID Token. |
/register |
POST | Dynamic client registration; returns an opaque client id. |
{
"webid": "https://idp.example/profile/0000-0001-2345-6789#me",
"cnf": { "jkt": "<thumbprint of DPoP pubkey>" },
"azp": "https://app.example/id",
"client_id": "https://app.example/id",
"iss": "https://idp.example",
"sub": "0000-0001-2345-6789",
"aud": ["https://app.example/id", "solid"],
"iat": 1700000000,
"exp": 1700003600,
"jti": "…"
}aud always includes the literal string solid per Solid-OIDC § 8.1.
POST /register
Content-Type: application/json
{
"client_name": "My Solid app",
"redirect_uris": ["https://app.example/cb"],
"scope": "openid webid offline_access",
"token_endpoint_auth_method": "none"
}The client_id returned is an opaque identifier of the form dyn_<32 hex chars> (e.g. dyn_5f3a8b1c2d4e6f7081929394a5b6c7d8). Only public clients (PKCE + DPoP, no client_secret) are supported — per Solid-OIDC § 5.2.
Per Solid-OIDC § 5.1, a client application may publish its own Client ID Document at a URL it controls (e.g. https://app.example/id) and use that URL as its client_id. When the IDP receives client_id=https://app.example/id at /authorize it dereferences the URL, validates the JSON-LD document against the solid oidc-context.jsonld, and uses it to validate the requested redirect_uri. The fetched document is cached in Netlify Blobs for CLIENT_ID_DOC_TTL_SECONDS (default 3600 s).
Documents are rejected unless:
Content-Typeisapplication/ld+json(orapplication/json),@contextcontainshttps://www.w3.org/ns/solid/oidc-context.jsonld,client_idequals the requested URL (self-reference, §5.1),redirect_urisis a non-empty array.
The fetcher refuses schemes other than http(s), refuses to follow redirects, and blocks loopback / RFC1918 / link-local / cloud-metadata hosts (SSRF defence). Local-only hostnames (e.g. localhost, 127.0.0.1, 169.254.169.254) are rejected.
All endpoints are reachable from a browser running on a different origin (Solid apps fetch /token, /.well-known/webfinger, /profile/:orcid, etc. cross-origin). Each response echoes the request Origin, sets Access-Control-Allow-Credentials: true, and Vary: Origin. The credentialed-POST endpoints (/token, /register) answer OPTIONS preflight with 204 and the appropriate Access-Control-Allow-Methods / Access-Control-Allow-Headers (Content-Type, DPoP, Authorization).
When the request has no Origin header (e.g. a server-to-server curl), no CORS headers are emitted.
┌──────────────┐
│ Solid App │ (web browser, CLI, server)
└──────┬───────┘
│ ① GET /authorize?response_type=code
│ &client_id=…&redirect_uri=…&code_challenge=…&code_challenge_method=S256
▼
┌──────────────────────────────────────────────────┐
│ Netlify: orcid-idp-netlify │
│ │
│ /.well-known/openid-configuration (static) │
│ /jwks.json (static) │
│ /.well-known/webfinger (function) │
│ /profile/:orcid (function) │
│ /authorize (function) │
│ /orcid/cb (function) │
│ /token (function) │
│ /register (function) │
│ │
│ Store (Netlify Blobs; netlify dev uses the │
│ local sandbox at .netlify/blobs-serve). │
│ InMemoryStore is reserved for tests. │
│ · auth-requests · codes │
│ · refresh-tokens · clients │
└──────────────────────────────────────────────────┘
│
│ ② redirect to ORCID's /oauth/authorize
▼
┌──────────────┐
│ ORCID │ (sandbox.orcid.org or orcid.org)
└──────┬───────┘
│ ③ user authenticates with ORCID
│ ④ ORCID redirects to /orcid/cb?code=…&state=…
▼
[ /orcid/cb exchanges ORCID code via openid-client ]
│
│ ⑤ redirect to client's redirect_uri?code=…&state=…
▼
┌──────────────┐
│ Solid App │ ⑥ POST /token with code, code_verifier, DPoP proof
│ │ ← id_token (DPoP-bound, webid=…)
└──────────────┘
Three tiers, all using vitest:
npm run test:unit # pure module tests (no I/O)
npm run test:integration # handler-level with in-memory store
npm run test:e2e # full code+PKCE+DPoP flow against a mock ORCIDThe e2e suite boots an in-process ORCID OIDC stand-in (tests/helpers/mock-orcid-server.ts) and a local HTTP server wrapping all production handlers, then walks a relying party through registration → authorize → ORCID → callback → token.
For an ORCID iD 0000-0001-2345-6789, this IDP serves:
- WebID URI:
${IDP_ISSUER}/profile/0000-0001-2345-6789#me - Profile document:
${IDP_ISSUER}/profile/0000-0001-2345-6789(Turtle) - Issuer:
${IDP_ISSUER}(also declared in the profile viasolid:oidcIssuer)
This is a static mapping per ORCID — no per-user registration is required. Any ORCID iD produces a valid WebID; the IDP is unopinionated about which ORCID iDs are allowed (operators can add an OrcidAuthFilter-equivalent on top if needed).
The profile document additionally declares foaf:name, foaf:givenName, foaf:familyName, and foaf:account <https://orcid.org/{orcid}> whenever the ORCID owner's name is publicly readable. The IDP fetches this on demand via ORCID's Public API using a service-level /read-public token (the same ORCID_CLIENT_ID / ORCID_CLIENT_SECRET, exchanged via grant_type=client_credentials with scope=/read-public); the token is cached in-process for its ~20-year lifetime. The call target is https://pub[.sandbox].orcid.org/v3.0/{orcid}/personal-details.
If the lookup fails (network blip, ORCID down, private name, missing fields), the profile is served without the name triples — the WebID and solid:oidcIssuer declaration remain intact, so Solid-OIDC discovery and authentication continue to work.
- Every feature lands as red → green → one conventional commit.
- Handlers are dependency-injected and fully covered by integration tests.
- All HTTP boundaries are exercised by the e2e suite.
- Storage abstraction (
Storeinterface) lets unit tests run withInMemoryStore; production andnetlify devuseNetlifyBlobsStoreso registrations persist across Lambda cold-starts.
MIT