Orionis

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.
Repository path
mkdir -p ~/go/src/github.com/stremovskyy
cd ~/go/src/github.com/stremovskyy
# put this repo here:
# ~/go/src/github.com/stremovskyy/orionis
Module:
module github.com/stremovskyy/orionis
What is inside
orionis/
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
Design goals
- 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.
Run locally
Terminal 1: authorization server.
cd ~/go/src/github.com/stremovskyy/orionis
go run ./cmd/orionis-auth -config ./config/orionis.example.json
Terminal 2: protected billing service.
cd ~/go/src/github.com/stremovskyy/orionis
go run ./examples/gin-billing-service
Terminal 3: orders service client.
cd ~/go/src/github.com/stremovskyy/orionis
go run ./examples/gin-orders-client
Expected 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-client
Run the PHP client when PHP is installed locally:
cd ~/go/src/github.com/stremovskyy/orionis
make run-php-client
If 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.php
Both 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
Run with Docker
Pull the published auth server image from Docker Hub:
docker pull stremovskyy/orionis:0.3.2
Or pull the GitHub Packages mirror from GitHub Container Registry:
docker pull ghcr.io/stremovskyy/orionis:0.3.2
Run 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.2
Or 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/readyz
Pin a released image in deployed environments:
ORIONIS_IMAGE_TAG=0.3.2 docker compose -f docker-compose.release.yml up -d
The 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.2
Deploy on AWS ECS Fargate
Use 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.json
Create 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.pem
Then 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-api
Run the orders demo client against the Compose network:
docker compose run --rm orders-client
Expected 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-orphans
The 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 .
Manual token request
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 | jq
Response:
{
"access_token": "eyJhbGciOiJFZERTQSIsImtpZCI6Im9yaW9uaXMtbG9jYWwtZWQyNTUxOS0x...",
"token_type": "Bearer",
"expires_in": 900,
"scope": "billing.invoice.create"
}
JWKS:
curl -s http://localhost:8080/.well-known/jwks.json | jq
Chain API cookbook
1. Calling service: add JWT automatically to outgoing HTTP requests
package 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 automatically
The provider caches tokens by audience + scopes, refreshes before expiration, and shares one token acquisition between concurrent goroutines.
2. Calling service: reuse one provider for several targets
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)
3. GIN resource service: protect routes through JWKS
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
}
4. GIN resource service: custom verifier
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()
5. Authorization server inside an existing GIN app
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
6. Static public key instead of remote JWKS
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)
JWT payload example
{
"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"
}
Configuration example
{
"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"]
}
]
}
Production-style service-to-service config
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.env
Add 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 | jq
Configure 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.json
Protect 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
admin or full_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.
Extension points
Custom client registry
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()
Custom signer
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.
Custom GIN error response
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()
Custom token request authentication
provider, _ := client.New().
TokenURL("https://auth.internal/oauth/token").
Authenticator(myPrivateKeyJWTAuthenticator).
For("billing-api", "billing.invoice.create").
Build()
Security notes
- 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_id per service.
- Always validate
iss, aud, exp, nbf, signature, token_use, and required scopes.
- Do not reuse a token minted for
billing-api against another audience.
- Keep the built-in rate limiting and audit logs enabled around
/oauth/token and /readyz.
- For stronger service identity, add mTLS or SPIFFE/SPIRE alongside JWT scopes.
Tests
go test ./...
Suggested production topology
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 summary
| 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 |