Skip to content

stremovskyy/orionis

Repository files navigation

Orionis

CI Docker Publish GitHub Release Go Reference Go Report Card Docker Image Version Docker Pulls Docker Image Size GHCR Docs Wiki License

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
}

JWKS validation lifecycle

  • 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 kid is 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 to 1h while it is still within the stale window
  • Static key providers are fully local and never call a JWKS endpoint

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

About

Service-to-service OAuth 2.0 client credentials toolkit for Go and Gin.

Topics

Resources

License

Stars

2 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors