A lightweight, standalone Identity Provider for generating anonymous JWTs using ECDSA keys.
- ECDSA Key Support: Signs tokens using ES256.
- Key Rotation: Automatically detects and reloads keys when the key file changes.
- JWKS Endpoint: Provides a
/.well-known/jwks.jsonendpoint for public key discovery. - Structured Logging: Supports both plain text and JSON-formatted logs.
- RFC 7519 Compliance: Includes RFC 7519 standard claims (
iss,iat,exp,jti). - Advanced Configuration:
- Client Whitelisting: Restricts token issuance to a predefined list of clients.
- Configuration Profiles: Define token profiles (
dev,prod) with specific TTLs and audiences.
- Rich JWT Claims: Includes
client_id,client_ip,device_id, and a configurableaud(audience) claim.
To receive a token, a client must send a POST request to the /sso/token endpoint with an application/x-www-form-urlencoded body.
| Parameter | Required | Description |
|---|---|---|
grant_type |
Yes | Must be set to client_credentials. |
client_id |
Yes | The unique identifier of the mobile client as defined in config.yaml. |
device_id |
Yes | Form parameter with a device identifier. Required for all mobile client token requests. Becomes the device_id claim in the JWT. |
Additional token endpoint requirements:
- Request method must be
POST. Content-Typemust beapplication/x-www-form-urlencoded.
# Mobile client with device_id form parameter
curl -X POST http://localhost:8080/sso/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&client_id=kompass-mobile-dev&device_id=my-device-identifier"The IDP validates the client_id and its associated configuration, then generates a JWT token with the provided device_id claim.
The token endpoint returns the following response on success:
{
"access_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6IjZZZGlSRzJLbnRZIiwianRrdSI6Imh0dHBzOi8vbG9jYWxob3N0Ojk2Nzcvd2VsbC1rbm93bi9qd2tzLmpzb24ifQ...",
"expires_in": 3600,
"token_type": "Bearer",
"scope": "guest"
}The generated JWT includes the following claims:
| Claim | Type | Description |
|---|---|---|
iss |
string | Issuer – the public host of the Ghost-IDP server. |
sub |
string | Subject – the device identifier (uniquely identifies the subject of the JWT). |
aud |
array | Audience – list of intended audiences (from config or defaults to client_id). |
client_id |
string | The client identifier that requested the token. |
client_ip |
string | The client's IP address (extracted from request headers or RemoteAddr). |
role |
string | Fixed role value: "guest". |
device_id |
string | (Mobile clients only) The device identifier provided in the request. |
iat |
number | Issued At – Unix timestamp when the token was created (RFC 7519 required). |
exp |
number | Expiration Time – Unix timestamp when the token expires (RFC 7519 required). |
jti |
string | JWT ID – Unique token identifier for replay attack prevention (RFC 7519 recommended). Format: jti_<20-char-hash> |
The token endpoint returns OAuth-style JSON errors:
{
"error": "invalid_request",
"error_description": "device_id is required"
}Common error codes:
invalid_request(missing/invalid request parameters)unsupported_grant_type(onlyclient_credentialsis supported)invalid_client(unknown or unauthorized client)invalid_request(origin validation failures for web clients)invalid_grant(attestation/token validation failed)temporarily_unavailable(signing key not ready)server_error(unexpected internal error)
Ghost-IDP implements comprehensive RFC standards for security and interoperability:
Implemented Claims:
iss(Issuer) – Identifies the principal that issued the JWTsub(Subject) – Identifies the principal that is the subject of the JWTaud(Audience) – Identifies the recipients that the JWT is intended foriat(Issued At) – Time at which the JWT was issued (Unix timestamp)exp(Expiration Time) – Time on which the JWT expires (Unix timestamp)jti(JWT ID) – Unique identifier for the JWT (prevents replay attacks)
Header Claims:
alg(Algorithm) – ES256 (ECDSA with SHA-256)kid(Key ID) – References the signing key in JWKSjku(JWKS URL) – Points to the/.well-known/jwks.jsonendpoint
Unique JTI Format:
- Format:
jti_<20-character-SHA256-hash> - Deterministic per client and timestamp
- Suitable for replay attack detection and token tracking
Token Endpoint (POST /sso/token):
- Implements
client_credentialsgrant type - Response includes:
access_token,token_type(Bearer),expires_in(seconds),scope - Cache-Control and Pragma headers prevent caching of sensitive responses
- Enforces
POSTmethod andapplication/x-www-form-urlencodedcontent type
Error Response Headers:
Cache-Control: no-store– Prevents cachingPragma: no-cache– Legacy cache preventionContent-Type: application/json– Proper content typeWWW-Authenticate: Bearer error="invalid_client"– For 401 responses (RFC 2617)
Error Codes: All error responses follow RFC 6749 Appendix B.1.3:
400 Bad Request–invalid_request(including origin validation),unsupported_grant_type401 Unauthorized–invalid_client500 Internal Server Error–server_error503 Service Unavailable–temporarily_unavailable
Proper HTTP status codes:
200 OK– Token successfully issued400 Bad Request– Invalid parameters401 Unauthorized– Authentication failed403 Forbidden– Authorization failed500 Internal Server Error– Server error503 Service Unavailable– Service temporarily unavailable
All sensitive responses include:
Cache-Control: no-store– Must not be cachedPragma: no-cache– Legacy cache prevention
Unix timestamps (seconds since epoch) for JWT claims (iat, exp).
WWW-Authenticateheader forinvalid_clienterrors- Proper Bearer token format in Authorization header
Test Coverage:
TestRFC7519RFC6749Compliance– Validates all RFC requirementsTestOAuth2ErrorResponseFormat– Validates error response formatTestOAuth2InvalidClientAuthentication– Validates authentication errors- See TESTING.md for full test coverage
Token endpoint responses include:
Cache-Control: no-storePragma: no-cache
The application is configured via config.yaml and can be extensively overridden with environment variables.
This file defines token profiles and allowed clients.
# config.yaml
# Public-facing host for the IDP
publicHost: http://localhost:8080
# Path to the ECDSA private key
keyPath: ./tls.key
attestation:
enabled: false
requiredFor:
- mobile
provider: noop
# Token configuration section
token:
# Global default TTL if not specified in a profile
ttl: 2h
# Optional random delay before issuing a token (default: 0ms, no delay)
tokenRequestDelay:
minMS: 0
maxMS: 0
# Reusable configuration profiles for tokens
config:
dev:
ttl: 2h
audience:
- kompass-dev
- kompass-qa
prod:
ttl: 4h
audience:
- kompass-prod
# Client definitions
clients:
mobile:
- name: "kompass-mobile-dev"
config: "dev"
- name: "kompass-mobile-prod"
config: "prod"The final configuration is determined in the following order (later steps override earlier ones):
- Hardcoded Defaults: The application has built-in defaults (e.g.,
token.ttl: 2h). config.yaml: Values from this file override the hardcoded defaults.- Environment Variables: Specific environment variables can override any of the above values. This is especially useful for managing token TTLs.
The Time-To-Live (TTL) for a token is resolved with the following priority:
- Profile-Specific Environment Variable:
TOKEN_CONFIG_<PROFILE>_TTL(e.g.,TOKEN_CONFIG_PROD_TTL=1h) will override everything for clients using that profile. - Profile
ttlinconfig.yaml: Thettldefined inside atoken.configprofile. - Global
TOKEN_TTLEnvironment Variable: Overrides the global default TTL. - Global
ttlinconfig.yaml: The top-leveltoken.ttlvalue. - Hardcoded Default:
2h.
| Variable | Description | Default |
|---|---|---|
JWT_KEY_PATH |
Path to the ECDSA private key file. | /etc/ghost-idp/certs/tls.key |
PUBLIC_HOST |
Public-facing host for the JWKS endpoint. | http://localhost:8080 |
LOG_FORMAT |
The format for application logs (text or json). |
text |
TOKEN_TTL |
Overrides the global default token TTL (e.g., 30d, 12h). |
token.ttl from config.yaml |
TOKEN_REQUEST_DELAY_MIN_MS |
Overrides the minimum token response delay in milliseconds. | 0 |
TOKEN_REQUEST_DELAY_MAX_MS |
Overrides the maximum token response delay in milliseconds. | 0 |
TOKEN_CONFIG_<PROFILE>_TTL |
Overrides the TTL for a specific profile (e.g., TOKEN_CONFIG_PROD_TTL=1h). |
ttl from the profile in config.yaml |
ATTESTATION_ENABLED |
Enables request-time attestation validation. | false |
ATTESTATION_REQUIRED_FOR |
Comma-separated client types requiring attestation (e.g. mobile). |
mobile |
ATTESTATION_PROVIDER |
Attestation provider id (noop scaffold by default). |
noop |
HIDE_ERROR_DESCRIPTION |
If true, token endpoint hides error descriptions (security). |
false |
- Prerequisites: Go 1.24 or higher.
- Generate a Key:
openssl ecparam -name prime256v1 -genkey -noout -out tls.key
- Create
config.yaml: Create the configuration file as shown above. - Run the Application:
go run .
- Build the Docker Image:
docker build -t ghost-idp:local . - Run the Docker Container:
docker run -p 8080:8080 \ -v $(pwd)/tls.key:/app/tls.key \ -v $(pwd)/config.yaml:/app/config.yaml \ -e JWT_KEY_PATH=/app/tls.key \ -e TOKEN_CONFIG_PROD_TTL=1h \ --name ghost-test \ ghost-idp:local
POST /sso/token: Generates and returns a new JWT for a validated client.GET /.well-known/jwks.json: Returns the JSON Web Key Set (JWKS). CORS enabled for public access (e.g. jwt.io).GET /.well-known/oauth-authorization-server: Returns OAuth 2.0 Authorization Server Metadata (RFC 8414) for automatic client discovery.GET /healthz: Liveness probe – always returns200 ok.GET /readyz: Readiness probe – returns200 readywhen the signing key is loaded,503otherwise.
The project includes comprehensive unit tests covering:
- 45+ tests covering:
- Configuration loading and precedence
- Token generation and validation
- JWKS endpoint
- Health (
/healthz) and readiness (/readyz) probes - Request logging with probe filtering
- Device ID handling for mobile clients
- CORS header validation
- Key management and rotation
- Logger (text and JSON format)
- Concurrent access to signing keys
# Run all tests
make test
# Run tests with coverage report
make test-coverage
# Run one specific test
go test -v -run TestTokenHandler_SuccessfulMobileToken- Request logger tests: Verify that health probes are properly filtered from logs
- CORS tests: Confirm that jwt.io can fetch public keys automatically
- Key management tests: Cover ECDSA key loading, PKCS#8 format, and concurrent access
- CI/CD: Tests run automatically on every push via GitHub Actions
See TESTING.md for detailed testing documentation.
Ghost-IDP includes an attestation scaffold that validates the device_id form value before issuing a JWT.
noopprovider:doc/noop.mermaid
- The mobile client sends a device identifier / attestation payload via the
device_idform parameter. - Ghost-IDP extracts the value and passes it to the configured
AttestationProvider. - If the provider accepts the token, the JWT will contain extra claims:
attested: trueattestation_level: "<value from provider>"
- If the provider rejects it, the request is denied with
400(missing) or403(invalid).
attestation:
enabled: false
requiredFor:
- mobile
provider: noopTo enforce attestation in runtime, set enabled: true:
attestation:
enabled: true
requiredFor:
- mobile
provider: noop
clients:
mobile:
- name: your-mobile-client
config: devOr via environment variables:
ATTESTATION_ENABLED=true
ATTESTATION_REQUIRED_FOR=mobile
ATTESTATION_PROVIDER=noopAll attestation logic lives in attestation.go.
To add another provider:
- Implement the interface — create a new file, e.g.
attestation_play_integrity.go:
package main
import (
"context"
"fmt"
"net/http"
)
type PlayIntegrityProvider struct {
// e.g. GoogleCredentials, ProjectID, etc.
}
func (p PlayIntegrityProvider) Verify(ctx context.Context, token string, r *http.Request, clientID, clientType string) (*AttestationResult, error) {
if token == "" {
return nil, fmt.Errorf("empty token")
}
return &AttestationResult{Level: "strong"}, nil
}- Register the provider — add a case in
initAttestationProvider()inattestation.go. - Set the provider in
config.yaml:
attestation:
enabled: true
provider: play-integrity- Android: Play Integrity API
- Apple: App Attest