Skip to content

Repository files navigation

orcid-idp-netlify

No maintenance intended Code quality: TDD vibe coded

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.

Prerequisites

Setup

1. Get ORCID sandbox credentials

  1. Register at https://sandbox.orcid.org/register (use a @mailinator.com email).
  2. Go to https://sandbox.orcid.org/developer-tools.
  3. 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)
  4. Save your client_id and client_secret.

2. Local development

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 dev

Build-time generates public/jwks.json, public/.well-known/openid-configuration. Runtime functions serve /.well-known/webfinger, /authorize, /orcid/cb, /token, /register, /profile/:orcid.

Environment variables

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

Endpoint reference

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.

ID Token shape

{
  "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.

Dynamic client registration

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.

Static (dereferenceable) client IDs

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-Type is application/ld+json (or application/json),
  • @context contains https://www.w3.org/ns/solid/oidc-context.jsonld,
  • client_id equals the requested URL (self-reference, §5.1),
  • redirect_uris is 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.

CORS

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.

Architecture

                      ┌──────────────┐
                      │  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=…)
                      └──────────────┘

Testing

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 ORCID

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

WebID convention

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 via solid: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).

Owner's name in the profile

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.

Development conventions

  • 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 (Store interface) lets unit tests run with InMemoryStore; production and netlify dev use NetlifyBlobsStore so registrations persist across Lambda cold-starts.

License

MIT

About

Solid IDP on Netlify using ORCID as identity source

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages