Orionis is a compact Go toolkit and GIN authorization server for service-to-service OAuth 2.0 client_credentials, signed JWT access tokens, JWKS validation, token caching, and drop-in GIN middleware.
The API is chain-first:
guard, err := ginorion.New().
Issuer("http://orionis-auth.internal").
Audience("billing-api").
JWKS("http://orionis-auth.internal/.well-known/jwks.json").
Build()The name comes from the Orion constellation: every service is a star, the authorization server is the gravity point, and JWKS is the star map. Slightly dramatic, but better than jwt-utils-final-final.
mkdir -p ~/go/src/github.com/stremovskyy
cd ~/go/src/github.com/stremovskyy
# put this repo here:
# ~/go/src/github.com/stremovskyy/orionisModule:
module github.com/stremovskyy/orionisorionis/
cmd/orionis-auth/ GIN authorization server
client/ OAuth2 client_credentials token provider + HTTP transport
ginorion/ GIN middleware + auth route registration
jwk/ JWKS types, Ed25519 signer, static/remote key providers
server/ OAuth2 token endpoint, JWKS endpoint, client registry
examples/gin-billing-service/ Protected GIN resource server example
examples/gin-orders-client/ Client service example that calls billing
examples/java-orders-client/ Java calling-service example without Maven dependencies
examples/php-orders-client/ PHP calling-service example without Composer dependencies
config/orionis.example.json Local development config
deploy/aws/ecs/ AWS ECS/Fargate task definition and guide
docs/architecture.md Architecture notes
docs/wiki/ GitHub Wiki source pages
site/ Static GitHub Pages site
- Chain-first API that is easy to read in existing services.
- KISS defaults: Ed25519, 15-minute token TTL, JWKS cache, Bearer JWT.
- Core packages are framework-agnostic; GIN integration is optional.
- Local JWT validation in resource services through JWKS.
- High-throughput client token cache with in-flight request de-duplication.
- Small interfaces for extension: client registry, signer, key provider, request authenticator, GIN error handler.
Terminal 1: authorization server.
cd ~/go/src/github.com/stremovskyy/orionis
go run ./cmd/orionis-auth -config ./config/orionis.example.jsonTerminal 2: protected billing service.
cd ~/go/src/github.com/stremovskyy/orionis
go run ./examples/gin-billing-serviceTerminal 3: orders service client.
cd ~/go/src/github.com/stremovskyy/orionis
go run ./examples/gin-orders-clientExpected client output:
status=201
{"amount":1500,"called_by":"orders-service","invoice_id":"inv_demo_001","order_id":"ord_demo_001","scope":"billing.invoice.create"}
The Java and PHP examples use the same local auth and billing services. They first request an OAuth2
client_credentials access token from Orionis, then call billing with Authorization: Bearer <token>.
Run the Java client:
cd ~/go/src/github.com/stremovskyy/orionis
make run-java-clientRun the PHP client when PHP is installed locally:
cd ~/go/src/github.com/stremovskyy/orionis
make run-php-clientIf PHP is not installed locally, run the example through Docker:
docker run --rm \
--add-host=host.docker.internal:host-gateway \
-v "$PWD:/work" \
-w /work \
-e ORIONIS_TOKEN_URL=http://host.docker.internal:8080/oauth/token \
-e BILLING_URL=http://host.docker.internal:8081/invoices \
php:8.4-cli php examples/php-orders-client/orders_client.phpBoth examples accept these environment variables:
ORIONIS_TOKEN_URL default: http://localhost:8080/oauth/token
BILLING_URL default: http://localhost:8081/invoices
ORIONIS_CLIENT_ID default: orders-service
ORIONIS_CLIENT_SECRET default: orders-local-secret-change-me
ORIONIS_AUDIENCE default: billing-api
ORIONIS_SCOPE default: billing.invoice.create
Pull the published auth server image from Docker Hub:
docker pull stremovskyy/orionis:0.3.2Or pull the GitHub Packages mirror from GitHub Container Registry:
docker pull ghcr.io/stremovskyy/orionis:0.3.2Run Orionis from the published image:
test -f config/orionis.json || cp config/orionis.example.json config/orionis.json
docker run --rm -d \
--name orionis-auth \
-p 8080:8080 \
-v "$PWD/config:/app/config:ro" \
-v orionis-var:/app/var \
stremovskyy/orionis:0.3.2Or use the release Compose file:
test -f config/orionis.json || cp config/orionis.example.json config/orionis.json
docker compose -f docker-compose.release.yml up -d
curl -fsS http://localhost:8080/healthz
curl -fsS http://localhost:8080/readyzPin a released image in deployed environments:
ORIONIS_IMAGE_TAG=0.3.2 docker compose -f docker-compose.release.yml up -dThe published image expects its config at /app/config/orionis.json by default.
Mount the config read-only, replace demo secrets before sharing an environment, and prefer
secret_sha256_hex over plaintext secrets in deployed configs. Local Docker examples use
/app/var for generated demo signing keys; ECS/Fargate should load signing keys from
injected secret environment variables instead.
The GitHub Container Registry image uses the same tags as Docker Hub:
docker pull ghcr.io/stremovskyy/orionis:0.3.2
docker run --rm -d \
--name orionis-auth \
-p 8080:8080 \
-v "$PWD/config:/app/config:ro" \
-v orionis-var:/app/var \
ghcr.io/stremovskyy/orionis:0.3.2Use the templates in deploy/aws/ecs to run the published image on ECS Fargate.
The default task definition uses stremovskyy/orionis:0.3.2, awsvpc networking, CloudWatch logs,
a /healthz container health check, /readyz readiness endpoint, AWS Secrets Manager for
ORIONIS_CONFIG_JSON, and AWS Secrets Manager secret injection for overlapping signing-key PEMs.
It does not mount EFS or write signing keys to /app/var.
Start from the example config and task definition:
cp deploy/aws/ecs/orionis-config.example.json /tmp/orionis-config.json
aws secretsmanager create-secret \
--name orionis/config \
--secret-string file:///tmp/orionis-config.jsonCreate active and previous signing-key secrets as PKCS8 PEM. The example ECS config publishes both
keys and signs new tokens with active_kid:
openssl genpkey -algorithm ED25519 > /tmp/orionis-ed25519.pem
openssl genpkey -algorithm ED25519 > /tmp/orionis-ed25519-old.pem
aws secretsmanager create-secret \
--name orionis/signing-key \
--secret-string file:///tmp/orionis-ed25519.pem
aws secretsmanager create-secret \
--name orionis/signing-key-old \
--secret-string file:///tmp/orionis-ed25519-old.pemThen follow deploy/aws/ecs/README.md to grant the ECS task execution
role access to the config and both signing-key secrets, render the task definition with your AWS resource placeholders, register
it, and deploy an ECS service.
Build the auth server image:
docker build --build-arg TARGET=./cmd/orionis-auth -t orionis-auth:local .Run the local auth and billing demo stack:
docker compose up --build --wait -d orionis-auth billing-apiRun the orders demo client against the Compose network:
docker compose run --rm orders-clientExpected client output:
status=201
{"amount":1500,"called_by":"orders-service","invoice_id":"inv_demo_001","order_id":"ord_demo_001","scope":"billing.invoice.create"}
Stop the stack without deleting the generated signing key:
docker compose down --remove-orphansThe Compose stack mounts config/ read-only and stores generated local Ed25519 private keys in the orionis-var named volume under /app/var. The example config uses /app/var/orionis-ed25519-1.pem and /app/var/orionis-ed25519-2.pem; the signer loader creates missing keys on first start. Use docker compose down -v only when you intentionally want to delete local demo keys.
The Dockerfile builds ./cmd/orionis-auth by default. To package another main package, override TARGET:
docker build --build-arg TARGET=./examples/gin-billing-service -t orionis-billing-demo:local .curl -s \
-u 'orders-service:orders-local-secret-change-me' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=client_credentials' \
-d 'audience=billing-api' \
-d 'scope=billing.invoice.create' \
http://localhost:8080/oauth/token | jqResponse:
{
"access_token": "eyJhbGciOiJFZERTQSIsImtpZCI6Im9yaW9uaXMtbG9jYWwtZWQyNTUxOS0x...",
"token_type": "Bearer",
"expires_in": 900,
"scope": "billing.invoice.create"
}JWKS:
curl -s http://localhost:8080/.well-known/jwks.json | jqpackage orders
import (
"net/http"
"github.com/stremovskyy/orionis/client"
)
func BillingHTTPClient() (*http.Client, error) {
return client.New().
TokenURL("http://orionis-auth.internal/oauth/token").
As("orders-service", "load-from-secrets-manager").
For("billing-api", "billing.invoice.create").
BuildHTTPClient(http.DefaultClient)
}Usage:
hc, _ := BillingHTTPClient()
req, _ := http.NewRequest("POST", "http://billing.internal/invoices", body)
req.Header.Set("Content-Type", "application/json")
res, err := hc.Do(req) // Authorization: Bearer <token> is added automaticallyThe provider caches tokens by audience + scopes, refreshes before expiration, and shares one token acquisition between concurrent goroutines.
provider, err := client.New().
TokenURL("http://orionis-auth.internal/oauth/token").
As("orders-service", "load-from-secrets-manager").
Build()
if err != nil {
return err
}
billingHTTP := provider.
For("billing-api", "billing.invoice.create").
HTTPClient(http.DefaultClient)
dispatchHTTP := provider.
For("dispatch-api", "dispatch.order.read").
HTTPClient(http.DefaultClient)package billing
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/stremovskyy/orionis/ginorion"
)
func Router() (*gin.Engine, error) {
guard, err := ginorion.New().
Issuer("http://orionis-auth.internal").
Audience("billing-api").
JWKS("http://orionis-auth.internal/.well-known/jwks.json").
Build()
if err != nil {
return nil, err
}
r := gin.Default()
r.POST("/invoices",
guard.Require("billing.invoice.create"),
func(c *gin.Context) {
claims := ginorion.MustClaims(c)
c.JSON(http.StatusCreated, gin.H{
"created_by_service": claims.ClientID,
})
},
)
return r, nil
}Build()creates the remote JWKS provider, but it does not fetch keys yet- The first protected request that needs a signing key calls the JWKS endpoin
- A request with an expired JWKS cache, or a token whose
kidis not in the current cache, also triggers a JWKS HTTP GET - When the cached key is fresh, JWT validation is entirely in memory. The resource service does not call the authorization server on every request.
- Validation checks the token signature, issuer, audience, time claims, allowed signing algorithm,
token_use, and route-required scopes - The default remote JWKS refresh interval is
10m, if refresh fails, an already cached key may be used for up to1hwhile it is still within the stale window - Static key providers are fully local and never call a JWKS endpoint
provider, _ := jwk.Remote("http://orionis-auth.internal/.well-known/jwks.json").Build()
verifier := orionis.NewVerifier().
Issuer("http://orionis-auth.internal").
Audience("billing-api").
Keys(provider)
guard, _ := ginorion.New().
Verifier(verifier).
Build()signer, _ := jwk.Ed25519().
Path("/run/secrets/orionis-ed25519.pem").
KID("orionis-prod-ed25519-1").
Build()
auth, _ := server.New().
Issuer("https://auth.internal.example.local").
AccessTokenTTL(15 * time.Minute).
Signer(signer).
Client(server.NewClient("orders-service").
Secret("load-from-secrets-manager").
Audience("billing-api").
Scopes("billing.invoice.create", "billing.invoice.read").
Defaults("billing.invoice.read"),
).
Build()
r := gin.Default()
ginorion.Auth(auth).Mount(r)Registered endpoints:
POST /oauth/token
GET /.well-known/jwks.json
GET /.well-known/openid-configuration
GET /healthz
GET /readyz
Useful for tests or very small deployments.
provider, _ := jwk.Static(signer.PublicJWK()).Build()
verifier := orionis.NewVerifier().
Issuer("http://localhost:8080").
Audience("billing-api").
Keys(provider){
"iss": "http://localhost:8080",
"sub": "orders-service",
"aud": ["billing-api"],
"exp": 1782900900,
"nbf": 1782900000,
"iat": 1782900000,
"jti": "0rYF8M77WyGLUuk0TwvT8A",
"client_id": "orders-service",
"scope": "billing.invoice.create",
"token_use": "access"
}{
"listen": ":8080",
"log_level": "info",
"issuer": "http://localhost:8080",
"access_token_ttl": "15m",
"active_kid": "orionis-local-ed25519-2",
"keys": [
{
"kid": "orionis-local-ed25519-1",
"private_key_path": "./var/orionis-ed25519-1.pem"
},
{
"kid": "orionis-local-ed25519-2",
"private_key_path": "./var/orionis-ed25519-2.pem"
}
],
"rate_limits": {
"token": {"enabled": true, "limit": 60, "window": "1m"},
"readyz": {"enabled": true, "limit": 300, "window": "1m"}
},
"audit_logs": {"enabled": true},
"clients": [
{
"id": "orders-service",
"secrets": ["orders-local-secret-change-me"],
"allowed_audiences": ["billing-api"],
"allowed_scopes": ["billing.invoice.create", "billing.invoice.read"],
"default_scopes": ["billing.invoice.read"]
}
]
}Use this pattern when one internal service needs access to another internal service. The caller is the OAuth client. The receiver is the audience. Scopes describe the allowed actions.
Do not put plaintext client secrets in the JSON config for shared or deployed environments.
Store only secret_sha256_hex in Orionis config and keep the plaintext secret in your secret manager or a local, untracked env file.
Create a client secret and hash it:
CLIENT_SECRET="$(openssl rand -base64 32)"
CLIENT_SECRET_SHA="$(printf '%s' "$CLIENT_SECRET" | openssl dgst -sha256 -r | awk '{print $1}')"
mkdir -p ./secrets
printf 'ORIONIS_CALLER_SERVICE_CLIENT_SECRET=%s\n' "$CLIENT_SECRET" >> ./secrets/client-secrets.env
chmod 600 ./secrets/client-secrets.envAdd the caller service to the auth server config:
{
"listen": ":8080",
"log_level": "info",
"issuer": "https://auth.example.internal",
"access_token_ttl": "15m",
"active_kid": "orionis-dev-ed25519-2",
"keys": [
{
"kid": "orionis-dev-ed25519-1",
"private_key_pem_env": "ORIONIS_SIGNING_KEY_PEM_OLD"
},
{
"kid": "orionis-dev-ed25519-2",
"private_key_pem_env": "ORIONIS_SIGNING_KEY_PEM"
}
],
"rate_limits": {
"token": {"enabled": true, "limit": 60, "window": "1m"},
"readyz": {"enabled": true, "limit": 300, "window": "1m"}
},
"audit_logs": {"enabled": true},
"clients": [
{
"id": "caller-service",
"secret_sha256_hex": ["<CLIENT_SECRET_SHA>"],
"allowed_audiences": ["target-api"],
"allowed_scopes": [
"target.webhooks.read",
"target.webhooks.resend",
"target.webhooks.admin.delete",
"target.products.read",
"target.products.write"
],
"default_scopes": ["target.webhooks.read"]
}
]
}allowed_scopes is the registry of concrete permissions that can be issued.
Callers may request multiple concrete scopes, or use wildcard selectors such as target.products.* for one segment and target.webhooks.** for recursive selection.
Wildcard selectors expand to matching concrete allowed_scopes; the token response and JWT scope claim contain only concrete scopes.
Wildcard entries may still be used in allowed_scopes as policy shortcuts for concrete requests, but they are not issued as token scopes.
Request a token from the calling service:
set -eu
. ./secrets/client-secrets.env
curl -fsS \
-u "caller-service:${ORIONIS_CALLER_SERVICE_CLIENT_SECRET}" \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=client_credentials' \
-d 'audience=target-api' \
-d 'scope=target.webhooks.* target.products.*' \
https://auth.example.internal/oauth/token | jqConfigure the receiving service to validate that token:
ORIONIS_ISSUER=https://auth.example.internal
ORIONIS_AUDIENCE=target-api
ORIONIS_JWKS_URL=https://auth.example.internal/.well-known/jwks.jsonProtect a GIN route in the receiving service:
guard, err := ginorion.New().
Issuer(os.Getenv("ORIONIS_ISSUER")).
Audience(os.Getenv("ORIONIS_AUDIENCE")).
JWKS(os.Getenv("ORIONIS_JWKS_URL")).
Build()
if err != nil {
return err
}
r.POST("/internal/action",
guard.Require("target.webhooks.resend"),
func(c *gin.Context) {
claims := ginorion.MustClaims(c)
c.JSON(http.StatusOK, gin.H{"called_by": claims.ClientID})
},
)Rules of thumb:
- Register a service as a client only when it calls another service.
- Register the receiving service name as an audience, not as a client, unless it also calls other services.
- Use narrow scopes for concrete actions instead of broad names like
adminorfull_access. - Use wildcard request selectors only for trusted service clients; route checks should still require concrete scopes.
- Keep example names, domains, hashes, and secrets as placeholders in documentation.
Implement server.ClientStore:
type ClientStore interface {
FindClient(ctx context.Context, id string) (server.Client, error)
}Then wire it:
auth, _ := server.New().
Issuer("https://auth.internal").
Signer(signer).
Store(myPostgresClientStore).
Build()Implement server.Signer:
type Signer interface {
KeyID() string
Algorithm() string
Sign(claims *orionis.Claims) (string, error)
PublicJWK() jwk.Key
}That allows RS256, PS256, ES256, AWS KMS, Vault Transit, or HSM-backed signing without changing clients or resource services.
guard, _ := ginorion.New().
Issuer(issuer).
Audience("billing-api").
JWKS(jwksURL).
ErrorHandler(func(c *gin.Context, status int, code string, err error) {
c.JSON(status, gin.H{"code": code})
}).
Build()provider, _ := client.New().
TokenURL("https://auth.internal/oauth/token").
Authenticator(myPrivateKeyJWTAuthenticator).
For("billing-api", "billing.invoice.create").
Build()- Use short access token TTLs: 5–15 minutes is usually a good service-token range.
- Store client secrets and private keys outside Git.
- Prefer one
client_idper service. - Always validate
iss,aud,exp,nbf, signature,token_use, and required scopes. - Do not reuse a token minted for
billing-apiagainst another audience. - Keep the built-in rate limiting and audit logs enabled around
/oauth/tokenand/readyz. - For stronger service identity, add mTLS or SPIFFE/SPIRE alongside JWT scopes.
go test ./...orders-service
client.Provider
client.Transport
|
| POST /oauth/token, Basic Auth, client_credentials
v
orionis-auth
/oauth/token
/.well-known/jwks.json
/readyz
|
| JWT access token
v
billing-service
ginorion.Guard
orionis.Verifier
jwk.RemoteProvider
| Package | Purpose |
|---|---|
orionis |
Claims, verifier, token response, scope helpers |
client |
Chain builder, token provider, cache, HTTP transport |
server |
Chain builder, OAuth token endpoint, JWKS endpoint, client store |
jwk |
Chain builders for JWKS providers and Ed25519 signer |
ginorion |
Chain builder for GIN guards and auth route mounting |