Skip to content

Commit 4825afa

Browse files
committed
feat(examples): add connectrpc-bearer demo
Add a runnable ConnectRPC Bearer-token example mirroring grpc-bearer: it serves the gRPC-style health service (connectrpc.com/grpchealth) behind the connectrpcsec authentication and authorization interceptors, and mints a demo JWT at start-up. The end-to-end test serves the handler over httptest and asserts the Connect protocol HTTP status mapping: a valid scoped token yields 200, a missing or garbage token 401, and a token without the scope 403.
1 parent 70b073b commit 4825afa

6 files changed

Lines changed: 248 additions & 11 deletions

File tree

examples/connectrpc-bearer/main.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright 2026 Hyperscale. All rights reserved.
2+
// Use of this source code is governed by a MIT
3+
// license that can be found in the LICENSE file.
4+
5+
// Command connectrpc-bearer is a runnable ConnectRPC Bearer-token demo.
6+
//
7+
// It exposes the gRPC-style health service behind two ConnectRPC interceptors:
8+
// one authenticates every RPC against a JWT, the other authorizes it against
9+
// an OAuth2 scope. The process also mints a demo token at start-up.
10+
//
11+
// Run:
12+
//
13+
// go run ./connectrpc-bearer
14+
//
15+
// The server logs a ready-to-use token. Probe it with curl over the Connect
16+
// protocol:
17+
//
18+
// curl -H "Authorization: Bearer <TOKEN>" \
19+
// -H "Content-Type: application/json" \
20+
// -d '{}' http://localhost:9091/grpc.health.v1.Health/Check
21+
//
22+
// Without the token the call fails with connect.CodeUnauthenticated (HTTP
23+
// 401); with a token that lacks the "health:read" scope it fails with
24+
// connect.CodePermissionDenied (HTTP 403).
25+
package main
26+
27+
import (
28+
"context"
29+
"crypto/ed25519"
30+
"crypto/rand"
31+
"fmt"
32+
"log"
33+
"net"
34+
"net/http"
35+
"time"
36+
37+
"connectrpc.com/connect"
38+
"connectrpc.com/grpchealth"
39+
"github.com/hyperscale-stack/security"
40+
"github.com/hyperscale-stack/security/bearer"
41+
connectrpcsec "github.com/hyperscale-stack/security/connectrpc"
42+
jwtsec "github.com/hyperscale-stack/security/jwt"
43+
"github.com/hyperscale-stack/security/voter"
44+
)
45+
46+
const (
47+
issuer = "https://issuer.example"
48+
audience = "https://connect.example"
49+
keyID = "demo-key"
50+
scope = "health:read"
51+
addr = ":9091"
52+
)
53+
54+
// minter signs a demo JWT carrying the requested scope.
55+
type minter func(scope string) (string, error)
56+
57+
// newServer builds the ConnectRPC handler with the security interceptors and
58+
// returns a token minter sharing the server's signing key. It is separate
59+
// from main so the end-to-end test can serve it over httptest.
60+
func newServer() (http.Handler, minter, error) {
61+
pub, priv, err := ed25519.GenerateKey(rand.Reader)
62+
if err != nil {
63+
return nil, nil, fmt.Errorf("generate key: %w", err)
64+
}
65+
66+
signer := jwtsec.NewSigner(jwtsec.PrivateKey{
67+
KeyID: keyID,
68+
Algorithm: jwtsec.EdDSA,
69+
Key: priv,
70+
})
71+
72+
jwks := jwtsec.NewStaticJWKS([]jwtsec.PublicKey{{
73+
KeyID: keyID,
74+
Algorithm: jwtsec.EdDSA,
75+
Key: pub,
76+
}})
77+
78+
verifier := jwtsec.NewVerifier(jwks,
79+
jwtsec.WithIssuer(issuer),
80+
jwtsec.WithAudience(audience),
81+
)
82+
83+
engine := security.NewEngine(
84+
security.NewManager(bearer.NewAuthenticator(jwtsec.BearerVerifier(verifier, nil))),
85+
bearer.NewExtractor(),
86+
)
87+
88+
adm := security.NewAffirmativeDecisionManager(voter.HasScope(scope))
89+
90+
path, handler := grpchealth.NewHandler(
91+
grpchealth.NewStaticChecker(grpchealth.HealthV1ServiceName),
92+
connect.WithInterceptors(
93+
connectrpcsec.NewAuthenticationInterceptor(engine),
94+
connectrpcsec.NewAuthorizationInterceptor(adm, []security.Attribute{security.Scope(scope)}),
95+
),
96+
)
97+
98+
mux := http.NewServeMux()
99+
mux.Handle(path, handler)
100+
101+
mint := func(grant string) (string, error) {
102+
now := time.Now()
103+
104+
token, err := signer.Sign(context.Background(), &jwtsec.StandardClaims{
105+
Issuer: issuer,
106+
Subject: "demo-user",
107+
Audience: jwtsec.Audience{audience},
108+
IssuedAt: jwtsec.NewNumericDate(now),
109+
ExpiresAt: jwtsec.NewNumericDate(now.Add(time.Hour)),
110+
Scope: grant,
111+
})
112+
if err != nil {
113+
return "", fmt.Errorf("mint token: %w", err)
114+
}
115+
116+
return token, nil
117+
}
118+
119+
return mux, mint, nil
120+
}
121+
122+
func main() {
123+
handler, mint, err := newServer()
124+
if err != nil {
125+
log.Fatalf("connectrpc-bearer: %v", err)
126+
}
127+
128+
token, err := mint(scope)
129+
if err != nil {
130+
log.Fatalf("connectrpc-bearer: %v", err)
131+
}
132+
133+
var lc net.ListenConfig
134+
135+
lis, err := lc.Listen(context.Background(), "tcp", addr) //nolint:gosec // G102: demo server, binding to all interfaces is intentional
136+
if err != nil {
137+
log.Fatalf("connectrpc-bearer: listen: %v", err)
138+
}
139+
140+
srv := &http.Server{
141+
Handler: handler,
142+
ReadHeaderTimeout: 5 * time.Second,
143+
}
144+
145+
log.Printf("connectrpc-bearer: listening on %s", addr)
146+
log.Printf("connectrpc-bearer: demo token: %s", token)
147+
log.Fatal(srv.Serve(lis))
148+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright 2026 Hyperscale. All rights reserved.
2+
// Use of this source code is governed by a MIT
3+
// license that can be found in the LICENSE file.
4+
5+
package main
6+
7+
import (
8+
"context"
9+
"net/http"
10+
"net/http/httptest"
11+
"strings"
12+
"testing"
13+
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func TestConnectRPCBearerExample(t *testing.T) {
19+
t.Parallel()
20+
21+
handler, mint, err := newServer()
22+
require.NoError(t, err)
23+
24+
srv := httptest.NewServer(handler)
25+
t.Cleanup(srv.Close)
26+
27+
goodToken, err := mint(scope)
28+
require.NoError(t, err)
29+
30+
wrongScopeToken, err := mint("other:read")
31+
require.NoError(t, err)
32+
33+
// check performs a Connect unary call against the health Check endpoint
34+
// and returns the HTTP status code. The Connect protocol maps the error
35+
// code onto the HTTP status (Unauthenticated -> 401, PermissionDenied ->
36+
// 403), so the status alone tells the outcome apart.
37+
check := func(t *testing.T, token string) int {
38+
t.Helper()
39+
40+
req, err := http.NewRequestWithContext(
41+
context.Background(),
42+
http.MethodPost,
43+
srv.URL+"/grpc.health.v1.Health/Check",
44+
strings.NewReader("{}"),
45+
)
46+
require.NoError(t, err)
47+
48+
req.Header.Set("Content-Type", "application/json")
49+
50+
if token != "" {
51+
req.Header.Set("Authorization", "Bearer "+token)
52+
}
53+
54+
resp, err := srv.Client().Do(req)
55+
require.NoError(t, err)
56+
require.NoError(t, resp.Body.Close())
57+
58+
return resp.StatusCode
59+
}
60+
61+
t.Run("valid token with the right scope succeeds", func(t *testing.T) {
62+
t.Parallel()
63+
assert.Equal(t, http.StatusOK, check(t, goodToken))
64+
})
65+
66+
t.Run("missing token is unauthorized", func(t *testing.T) {
67+
t.Parallel()
68+
assert.Equal(t, http.StatusUnauthorized, check(t, ""))
69+
})
70+
71+
t.Run("garbage token is unauthorized", func(t *testing.T) {
72+
t.Parallel()
73+
assert.Equal(t, http.StatusUnauthorized, check(t, "not-a-jwt"))
74+
})
75+
76+
t.Run("valid token without the scope is forbidden", func(t *testing.T) {
77+
t.Parallel()
78+
assert.Equal(t, http.StatusForbidden, check(t, wrongScopeToken))
79+
})
80+
}

examples/doc.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@
1212
//
1313
// Available examples:
1414
//
15-
// - basic-http — HTTP Basic authentication + role-based authorization.
16-
// - bearer-jwt — JWT issuance and Bearer-token validation, scope gating.
17-
// - grpc-bearer — gRPC unary interceptors authenticating a Bearer JWT.
18-
// - session-web — cookie-session login form with a CSRF-protected logout.
19-
// - oauth2 — OAuth2 authorization server + Bearer resource server.
15+
// - basic-http — HTTP Basic authentication + role-based authorization.
16+
// - bearer-jwt — JWT issuance and Bearer-token validation, scope gating.
17+
// - grpc-bearer — gRPC unary interceptors authenticating a Bearer JWT.
18+
// - connectrpc-bearer — ConnectRPC interceptors authenticating a Bearer JWT.
19+
// - session-web — cookie-session login form with a CSRF-protected logout.
20+
// - oauth2 — OAuth2 authorization server + Bearer resource server.
2021
package examples

examples/go.mod

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ replace github.com/hyperscale-stack/security/http => ../http
99

1010
replace github.com/hyperscale-stack/security/grpc => ../grpc
1111

12+
replace github.com/hyperscale-stack/security/connectrpc => ../connectrpc
13+
1214
replace github.com/hyperscale-stack/security/basic => ../basic
1315

1416
replace github.com/hyperscale-stack/security/bearer => ../bearer
@@ -22,9 +24,12 @@ replace github.com/hyperscale-stack/security/session => ../session
2224
replace github.com/hyperscale-stack/security/oauth2 => ../oauth2
2325

2426
require (
27+
connectrpc.com/connect v1.20.0
28+
connectrpc.com/grpchealth v1.4.0
2529
github.com/hyperscale-stack/security v0.0.0-00010101000000-000000000000
2630
github.com/hyperscale-stack/security/basic v0.0.0-00010101000000-000000000000
2731
github.com/hyperscale-stack/security/bearer v0.0.0-00010101000000-000000000000
32+
github.com/hyperscale-stack/security/connectrpc v0.0.0-00010101000000-000000000000
2833
github.com/hyperscale-stack/security/grpc v0.0.0-00010101000000-000000000000
2934
github.com/hyperscale-stack/security/http v0.0.0-00010101000000-000000000000
3035
github.com/hyperscale-stack/security/jwt v0.0.0-00010101000000-000000000000
@@ -51,6 +56,6 @@ require (
5156
golang.org/x/sys v0.44.0 // indirect
5257
golang.org/x/text v0.37.0 // indirect
5358
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect
54-
google.golang.org/protobuf v1.36.1 // indirect
59+
google.golang.org/protobuf v1.36.11 // indirect
5560
gopkg.in/yaml.v3 v3.0.1 // indirect
5661
)

examples/go.sum

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
2+
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
3+
connectrpc.com/grpchealth v1.4.0 h1:MJC96JLelARPgZTiRF9KRfY/2N9OcoQvF2EWX07v2IE=
4+
connectrpc.com/grpchealth v1.4.0/go.mod h1:WhW6m1EzTmq3Ky1FE8EfkIpSDc6TfUx2M2KqZO3ts/Q=
15
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
26
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
37
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -49,8 +53,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 h1:
4953
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA=
5054
google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU=
5155
google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
52-
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
53-
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
56+
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
57+
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
5458
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
5559
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
5660
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

go.work.sum

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1F
77
cloud.google.com/go/iam v1.2.2/go.mod h1:0Ys8ccaZHdI1dEUilwzqng/6ps2YB6vRsjIe00/+6JY=
88
cloud.google.com/go/monitoring v1.21.2/go.mod h1:hS3pXvaG8KgWTSz+dAdyzPrGUYmi2Q+WFX8g2hqVEZU=
99
cloud.google.com/go/storage v1.49.0/go.mod h1:k1eHhhpLvrPjVGfo0mOUPEJ4Y2+a/Hv5PiwehZI9qGU=
10-
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
10+
connectrpc.com/grpchealth v1.4.0 h1:MJC96JLelARPgZTiRF9KRfY/2N9OcoQvF2EWX07v2IE=
11+
connectrpc.com/grpchealth v1.4.0/go.mod h1:WhW6m1EzTmq3Ky1FE8EfkIpSDc6TfUx2M2KqZO3ts/Q=
1112
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM=
1213
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1/go.mod h1:jyqM3eLpJ3IbIFDTKVz2rF9T/xWGW0rIriGwnz8l9Tk=
1314
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1/go.mod h1:viRWSEhtMZqz1rhwmOVKkWl6SwmVowfL9O2YR5gI2PE=
@@ -69,6 +70,4 @@ google.golang.org/grpc v1.67.3/go.mod h1:YGaHCc6Oap+FzBJTZLBzkGSYt/cvGPFTPxkn7Qf
6970
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
7071
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
7172
google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
72-
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
73-
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
7473
modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=

0 commit comments

Comments
 (0)