Go-based starting point for the MuninID Identity Provider. It is built to mirror the existing Node IDP as closely as practical while keeping the code small, explicit, and easy to continue from.
The current implementation uses:
chifor HTTP routing and middleware.- PostgreSQL through
pgx. - Goose SQL migrations through a separate migration command.
- The existing MuninID database tables where possible.
github.com/tenforwardab/wildduck-gosdkfrom its tagged Go module release.- The same
enc:v1AES-256-GCM secret format as the Node IDP. - RS256 JWT access/id tokens and JWKS.
- A Fosite dependency is included for the OAuth/OIDC migration path, but this first base keeps the endpoint behavior explicit instead of hiding it behind a large storage adapter.
Implemented:
/.well-known/openid-configuration/oauth/jwks.json/oauth/authorize/interaction/{uid}/interaction/{uid}/login/oauth/token/oauth/introspect/oauth/revoke/oauth/logout/userinfo/gui/gui/api/clients/gui/api/policies/gui/api/sps/api/global/admin/clients/api/global/admin/policies/api/global/admin/sps/api/admin/clients- WildDuck username/password authentication
- PKCE
S256authorization code flow - Refresh token flow
- Client credentials flow
- RFC 8693 access-token exchange with database policy checks and event logging
- Tenant-scoped client administration based on token claims
- Global SAML service provider admin CRUD
- Global identity policy admin CRUD
- Existing
oidc_clients,jwt_rsa256_keys,oidc_adapter_store, andlogin_attemptstables
Scaffolded/not yet exact:
- SAML service provider records are persisted through admin CRUD, but no SAML runtime flow consumes them yet.
- Identity policy records are persisted through admin CRUD, but login/token policy enforcement is not wired yet.
- Token exchange is implemented for access-token to access-token exchanges; other token types are intentionally rejected.
- The Node
oidc-providerinteraction cookies are not binary-compatible. The Go service uses its own signedidp.sid. - The current OAuth engine is explicit Go code; wiring Fosite as the token engine is the next hardening step if you want full RFC edge-case coverage.
cp .env.example .env
set -a
. ./.env
set +a
go run ./cmd/muninidThis repo includes Goose SQL migrations translated from the Sequelize migrations in:
/home/andrek/dev/gits/muninid/src/migrations
Run migrations with:
DATABASE_URL=postgres://postgres:postgres@localhost:5432/muninid?sslmode=disable \
go run ./cmd/muninid-migrate upOther Goose commands are passed through:
go run ./cmd/muninid-migrate status
go run ./cmd/muninid-migrate down
go run ./cmd/muninid-migrate redo
go run ./cmd/muninid-migrate versionYou can also pass flags explicitly:
go run ./cmd/muninid-migrate \
-database-url "$DATABASE_URL" \
-dir ./migrations \
upThe migration command uses Goose with Go's database/sql and the pgx stdlib driver. Runtime application queries still use plain pgxpool in internal/store.
The migrations create/maintain:
jwt_rsa256_keysoidc_adapter_storeoidc_clientslogin_attemptssaml_service_providersidentity_policiestoken_exchange_policiestoken_exchange_eventsadmin_audit_events
The SQL uses the same timestamp-based versions as the Sequelize migrations and preserves the same mixed-case quoted column names expected by the current Go store and Node IDP. It also uses IF NOT EXISTS/guarded DDL where practical so a partially migrated development database is easier to recover.
If the Node IDP has already migrated a database, do not blindly run Goose up against production before reconciling goose_db_version. For a fresh Go-managed database, run Goose before starting the server.
Required environment variables:
DATABASE_URLWILDDUCK_API_URLWILDDUCK_API_TOKENOIDC_COOKIE_KEYSIDP_SECRET_ENCRYPTION_KEY
Useful optional variables:
HOST, default0.0.0.0PORT, default8080OIDC_ISSUER, defaulthttp://localhost:${PORT}CORS_ORIGINS, comma-separatedOIDC_TRUSTED_CONSENT_DOMAINS, comma-separated domains that auto-grant consent for first-party clients, defaultmuninid.local,mailtrix.euADMIN_API_KEY, required for/api/global/admin/*ENABLE_GUI, set totrueto enable the simple admin UI at/guiMASTER_USER, defaultidp_admin, used for/guiBasic AuthMASTER_PASSWORD, required whenENABLE_GUI=true
IDP_SECRET_ENCRYPTION_KEY must match the existing Node IDP value if you want this Go service to read existing encrypted client secrets and signing keys.
The old simple Node admin UI is available at:
http://localhost:8080/gui
Enable it with:
ENABLE_GUI=true
MASTER_USER=idp_admin
MASTER_PASSWORD=change-meThe page and its backing /gui/api/* routes are protected with HTTP Basic Auth using MASTER_USER and MASTER_PASSWORD. The UI exposes the same resources as the Node view:
clientspoliciessps
Client CRUD is wired to the Go admin store and returns newly generated client secrets on create or rotation. policies and sps are also wired to persistent admin CRUD for parity with the current Node IDP admin surface. They are intentionally records-only for now: policy enforcement and SAML runtime behavior are future integration work.
Global admin routes use X-Admin-Api-Key.
curl -H "X-Admin-Api-Key: $ADMIN_API_KEY" \
http://localhost:8080/api/global/admin/clientsCreate a client:
curl -X POST http://localhost:8080/api/global/admin/clients \
-H "X-Admin-Api-Key: $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "MuninID UI",
"redirect_uris": ["http://localhost:5173/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"scopes": ["openid", "profile", "email", "account", "offline_access"]
}'Tenant admin routes use a bearer token with roles containing idp_admin, admin, or superadmin, and scope clients by customer_id.
Create an identity policy record:
curl -X POST http://localhost:8080/api/global/admin/policies \
-H "X-Admin-Api-Key: $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "require-2fa",
"target_type": "client",
"policy": {"rule": "allow"}
}'Create a SAML service provider record:
curl -X POST http://localhost:8080/api/global/admin/sps \
-H "X-Admin-Api-Key: $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"entity_id": "urn:example:sp",
"acs": ["https://app.example.com/saml/acs"],
"binding": "post",
"attr_mapping": {"email": "mail"}
}'The authorize endpoint requires PKCE S256, matching the stricter Node configuration:
GET /oauth/authorize?
response_type=code&
client_id=...&
redirect_uri=...&
scope=openid profile email account offline_access&
code_challenge=...&
code_challenge_method=S256&
state=...
If no idp.sid session exists, the user is redirected to:
/interaction/{uid}
The login form authenticates with WildDuck, stores a signed session, issues an authorization code, and redirects back to the registered redirect URI.
Supported grant types:
authorization_coderefresh_tokenclient_credentialsurn:ietf:params:oauth:grant-type:token-exchange
Client authentication supports:
client_secret_basicclient_secret_post
Refresh tokens are only issued for user authorization-code flows when both are true:
- the client has
refresh_tokeningrant_types - the requested/approved scope contains
offline_access
The refresh grant is rejected for clients that do not have refresh_token in grant_types. Refresh tokens are rotated on use: a successful refresh deletes the old opaque refresh token and, when offline_access is still present, returns a new one.
Requested scopes are validated against the registered client scopes for authorization-code, refresh-token, and client-credentials flows. client_credentials requests default to all registered client scopes when no scope parameter is supplied.
Revocation supports both JWT access tokens and opaque refresh tokens. Introspection returns JWT access-token claims when active and can also report active refresh tokens for the authenticated owning client.
Token exchange is deliberately conservative. It only supports exchanging an access token for a new access token, and the authenticated client must:
- include
urn:ietf:params:oauth:grant-type:token-exchangein itsgrant_types - present a valid
subject_tokenissued to the same client - request exactly one
audienceorresource - match an enabled row in
token_exchange_policies - request scopes that are already present on the subject token and allowed by the policy
Every successful or failed exchange attempt is written to token_exchange_events.
Example policy:
INSERT INTO token_exchange_policies (
"clientId",
priority,
subject,
"subjectTokenTypes",
audiences,
scopes,
"actorTokenRequired",
enabled,
description
) VALUES (
'your-client-id',
100,
'*',
'["urn:ietf:params:oauth:token-type:access_token"]'::jsonb,
'["https://api.example.com"]'::jsonb,
'["openid", "profile", "email", "account"]'::jsonb,
false,
true,
'Allow this client to exchange user access tokens for the API audience'
);Example request:
curl -X POST http://localhost:8080/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=$ACCESS_TOKEN" \
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "requested_token_type=urn:ietf:params:oauth:token-type:access_token" \
-d "audience=https://api.example.com" \
-d "scope=openid profile email"cmd/muninid/ executable entrypoint
internal/app/ router and middleware wiring
internal/config/ environment configuration
internal/handlers/ interaction and admin handlers
internal/idp/ OAuth/OIDC behavior, JWT/JWKS, WildDuck claims
internal/secret/ Node-compatible enc:v1 secret encryption
internal/store/ PostgreSQL access for existing IDP tables
migrations/ Goose SQL migrations matching Node Sequelize migrations
GOCACHE=/tmp/muninid-gocache \
GOTMPDIR=/tmp/muninid-gotmp \
go test ./...Current result:
ok/build: all packages compile
MuninID is licensed under EUPL-1.2. Direct third-party dependency license texts are collected in LICENSES/
- Replace the explicit OAuth code path with a full Fosite storage implementation if strict RFC conformance is required.
- Wire identity policy enforcement into login/token decisions.
- Wire SAML service provider records into a real SAML runtime flow.
- Add integration tests against a migrated MuninID database and a test WildDuck instance.
- Add audit logging to
admin_audit_events.