Mint and verify short-lived RS256 user tokens.

ExApps sign after AppAPI auth; Microservices check a static public key or an OIDC issuer and forward the same bearer.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-28 14:03:52 +02:00
co-authored by Cursor
parent 92feaa6915
commit f082561cc6
13 changed files with 1091 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# go-usertoken
User tokens an ExApp mints for Microservices. Import `gitea.neitzel.de/konrad/go-usertoken`.
## Language
**User token**:
A short-lived RS256 JWT whose `sub` is the Nextcloud user id. Optional `groups` is a snapshot taken when the ExApp minted it. Microservices forward the same token to each other.
_Avoid_: AppAPI secret, access token (too broad), session
**Caller**:
The user id, username, and optional groups read from a verified user token.
_Avoid_: Requesting user (that is the AppAPI name, before a token exists), principal
**Signer**:
The ExApp-side minter. It holds the only private key.
_Avoid_: issuer (the `iss` string), identity server
**Static key**:
The verify mode that checks a user token with a configured RSA public key and a fixed `iss` string. No discovery URL.
_Avoid_: JWKS, OIDC
**OIDC issuer**:
The verify mode that discovers keys at an identity server (for example Keycloak). One Microservice process uses this mode or static key, not both.
_Avoid_: running discovery on the ExApp
+67
View File
@@ -1,2 +1,69 @@
# go-usertoken
Short-lived RS256 user tokens from a Nextcloud ExApp to Go Microservices.
Import: `gitea.neitzel.de/konrad/go-usertoken` (package `usertoken`).
The ExApp mints a token after AppAPI has named the user. Each Microservice checks that token. One process trusts either a static public key or an OIDC issuer. The same bearer is forwarded when a Microservice calls another.
Procedure and claim rules: Knowledge `platforms/nextcloud/exapps/authentication.md`. Domain words: [CONTEXT.md](./CONTEXT.md).
This module does not speak AppAPI and does not see `APP_SECRET`.
## ExApp
```go
signer, err := usertoken.NewSignerFromEnv()
raw, err := signer.Mint(time.Now(), usertoken.MintInput{
Subject: userID,
Groups: &groupIDs, // nil omits groups
})
req.Header.Set("Authorization", "Bearer "+raw)
```
| Variable | Role |
| --- | --- |
| `USER_TOKEN_PRIVATE_KEY_FILE` | RSA private key PEM |
| `USER_TOKEN_ISSUER` | `iss` string |
| `USER_TOKEN_AUDIENCE` | `aud` string |
| `USER_TOKEN_TTL` | optional, default `5m` |
## Microservice
```go
auth, err := usertoken.FromEnv(ctx)
handler = auth.Middleware()(handler)
caller, _ := usertoken.CallerFromContext(r.Context())
raw, _ := usertoken.BearerFromContext(r.Context())
out.Header.Set("Authorization", "Bearer "+raw)
```
`/health` is not authenticated. Any other path without a valid bearer is 401.
Set one of these. Setting both, or neither, makes `FromEnv` fail.
Static public key (ExApp-minted tokens):
| Variable | Role |
| --- | --- |
| `USER_TOKEN_PUBLIC_KEY_FILE` | RSA public key PEM |
| `USER_TOKEN_ISSUER` | expected `iss` |
| `USER_TOKEN_AUDIENCE` | expected `aud` |
| `USER_TOKEN_SKEW` | optional, default `1m` |
OIDC issuer (Keycloak or another identity server):
| Variable | Role |
| --- | --- |
| `OIDC_ISSUER` | issuer URL that serves discovery |
| `OIDC_AUDIENCE` | expected `aud`; empty skips the check |
| `OIDC_GROUPS_CLAIM` | group array claim, default `groups` (`identity_groups` for current Keycloak tokens) |
| `USER_TOKEN_SKEW` | optional, default `1m` |
```bash
openssl genrsa -out user-token.key 2048
openssl rsa -in user-token.key -pubout -out user-token.pub
```
The private key stays on the ExApp. Static mode copies `user-token.pub` to each Microservice.
+131
View File
@@ -0,0 +1,131 @@
package usertoken
import (
"context"
"crypto/rsa"
"fmt"
"net/http"
"os"
"strings"
"time"
)
// Config selects one verify mode.
// Set PublicKey for static RS256, or OIDCIssuer for discovery. Not both.
// Skew of zero uses 1 minute. GroupsClaim empty uses "groups" (OIDC only).
// Audience empty is allowed only for OIDC, where it skips the audience check.
type Config struct {
PublicKey *rsa.PublicKey
Issuer string
Audience string
OIDCIssuer string
OIDCAudience string
GroupsClaim string
Skew time.Duration
}
// Auth verifies bearers for one Microservice process.
type Auth struct {
v tokenVerifier
}
// FromEnv reads the process environment. See the authentication procedure for names.
// Both USER_TOKEN_PUBLIC_KEY_FILE and OIDC_ISSUER set, or neither set, is an error.
func FromEnv(ctx context.Context) (*Auth, error) {
pubFile := strings.TrimSpace(os.Getenv("USER_TOKEN_PUBLIC_KEY_FILE"))
oidcIss := strings.TrimSpace(os.Getenv("OIDC_ISSUER"))
switch {
case pubFile != "" && oidcIss != "":
return nil, fmt.Errorf("set USER_TOKEN_PUBLIC_KEY_FILE or OIDC_ISSUER, not both")
case pubFile == "" && oidcIss == "":
return nil, fmt.Errorf("set USER_TOKEN_PUBLIC_KEY_FILE or OIDC_ISSUER")
}
var skew time.Duration
if raw := strings.TrimSpace(os.Getenv("USER_TOKEN_SKEW")); raw != "" {
var err error
skew, err = time.ParseDuration(raw)
if err != nil {
return nil, fmt.Errorf("USER_TOKEN_SKEW: %w", err)
}
}
cfg := Config{
Issuer: os.Getenv("USER_TOKEN_ISSUER"),
Audience: os.Getenv("USER_TOKEN_AUDIENCE"),
OIDCIssuer: oidcIss,
OIDCAudience: os.Getenv("OIDC_AUDIENCE"),
GroupsClaim: os.Getenv("OIDC_GROUPS_CLAIM"),
Skew: skew,
}
if pubFile != "" {
key, err := readPublicKeyFile(pubFile)
if err != nil {
return nil, err
}
cfg.PublicKey = key
}
return New(ctx, cfg)
}
// New builds an Auth from explicit configuration. ctx is used for OIDC discovery.
func New(ctx context.Context, cfg Config) (*Auth, error) {
static := cfg.PublicKey != nil
oidcOn := strings.TrimSpace(cfg.OIDCIssuer) != ""
switch {
case static && oidcOn:
return nil, fmt.Errorf("set a public key or an OIDC issuer, not both")
case !static && !oidcOn:
return nil, fmt.Errorf("set a public key or an OIDC issuer")
}
var v tokenVerifier
var err error
if static {
v, err = newStaticVerifier(cfg.PublicKey, cfg.Issuer, cfg.Audience, cfg.Skew)
} else {
v, err = newOIDCVerifier(ctx, cfg.OIDCIssuer, cfg.OIDCAudience, cfg.GroupsClaim, cfg.Skew)
}
if err != nil {
return nil, err
}
return &Auth{v: v}, nil
}
// Verify checks a raw JWT (no "Bearer " prefix).
func (a *Auth) Verify(ctx context.Context, raw string) (Caller, error) {
return a.v.verify(ctx, raw)
}
// Middleware requires a bearer on every path except /health.
// Success stores the Caller and the raw JWT on the request context.
func (a *Auth) Middleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/health" {
next.ServeHTTP(w, r)
return
}
raw, ok := bearerToken(r.Header.Get("Authorization"))
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
caller, err := a.Verify(r.Context(), raw)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r.WithContext(withAuth(r.Context(), caller, raw)))
})
}
}
func bearerToken(h string) (string, bool) {
scheme, rest, ok := strings.Cut(h, " ")
if !ok || !strings.EqualFold(scheme, "Bearer") {
return "", false
}
rest = strings.TrimSpace(rest)
if rest == "" {
return "", false
}
return rest, true
}
+310
View File
@@ -0,0 +1,310 @@
package usertoken
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func testKey(t *testing.T) *rsa.PrivateKey {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate key: %v", err)
}
return key
}
func testAuth(t *testing.T, key *rsa.PrivateKey) *Auth {
t.Helper()
auth, err := New(t.Context(), Config{
PublicKey: &key.PublicKey,
Issuer: "manager",
Audience: "mgmnt",
})
if err != nil {
t.Fatalf("New: %v", err)
}
return auth
}
func testSigner(t *testing.T, key *rsa.PrivateKey) *Signer {
t.Helper()
s, err := NewSigner(SignConfig{PrivateKey: key, Issuer: "manager", Audience: "mgmnt"})
if err != nil {
t.Fatalf("NewSigner: %v", err)
}
return s
}
func TestMintAndVerify(t *testing.T) {
key := testKey(t)
signer := testSigner(t, key)
auth := testAuth(t, key)
groups := []string{"family"}
raw, err := signer.Mint(time.Now(), MintInput{Subject: "konrad", Groups: &groups})
if err != nil {
t.Fatalf("Mint: %v", err)
}
caller, err := auth.Verify(t.Context(), raw)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if caller.Subject != "konrad" || caller.Username != "konrad" {
t.Fatalf("caller = %+v", caller)
}
if len(caller.Groups) != 1 || caller.Groups[0] != "family" {
t.Fatalf("groups = %#v", caller.Groups)
}
}
func TestGroupsOmitVsEmpty(t *testing.T) {
key := testKey(t)
signer := testSigner(t, key)
auth := testAuth(t, key)
now := time.Now()
omitted, err := signer.Mint(now, MintInput{Subject: "konrad"})
if err != nil {
t.Fatalf("Mint omit: %v", err)
}
c, err := auth.Verify(t.Context(), omitted)
if err != nil {
t.Fatalf("Verify omit: %v", err)
}
if c.Groups != nil {
t.Fatalf("omitted groups = %#v, want nil", c.Groups)
}
empty := []string{}
present, err := signer.Mint(now, MintInput{Subject: "konrad", Groups: &empty})
if err != nil {
t.Fatalf("Mint empty: %v", err)
}
payload := decodePayload(t, present)
if !strings.Contains(payload, `"groups":[]`) {
t.Fatalf("payload = %s", payload)
}
c, err = auth.Verify(t.Context(), present)
if err != nil {
t.Fatalf("Verify empty: %v", err)
}
if c.Groups == nil || len(c.Groups) != 0 {
t.Fatalf("empty groups = %#v, want empty slice", c.Groups)
}
}
func TestVerifyRejects(t *testing.T) {
key := testKey(t)
other := testKey(t)
signer := testSigner(t, key)
auth := testAuth(t, key)
now := time.Now()
good, err := signer.Mint(now, MintInput{Subject: "konrad"})
if err != nil {
t.Fatalf("Mint: %v", err)
}
otherAuth := testAuth(t, other)
if _, err := otherAuth.Verify(t.Context(), good); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("wrong key err = %v", err)
}
expired := signClaims(t, key, tokenClaims{
Issuer: "manager",
Subject: "konrad",
Audience: jwt.ClaimStrings{"mgmnt"},
IssuedAt: jwt.NewNumericDate(now.Add(-time.Hour)),
ExpiresAt: jwt.NewNumericDate(now.Add(-30 * time.Minute)),
PreferredUsername: "konrad",
})
if _, err := auth.Verify(t.Context(), expired); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("expired err = %v", err)
}
future := signClaims(t, key, tokenClaims{
Issuer: "manager",
Subject: "konrad",
Audience: jwt.ClaimStrings{"mgmnt"},
IssuedAt: jwt.NewNumericDate(now.Add(time.Hour)),
ExpiresAt: jwt.NewNumericDate(now.Add(2 * time.Hour)),
PreferredUsername: "konrad",
})
if _, err := auth.Verify(t.Context(), future); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("future iat err = %v", err)
}
parts := splitToken(t, good)
none := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + "." + parts[1] + "."
if _, err := auth.Verify(t.Context(), none); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("alg none err = %v", err)
}
}
func TestMiddlewareForwardsBearer(t *testing.T) {
key := testKey(t)
signer := testSigner(t, key)
auth := testAuth(t, key)
raw, err := signer.Mint(time.Now(), MintInput{Subject: "konrad"})
if err != nil {
t.Fatalf("Mint: %v", err)
}
var gotRaw string
var gotSubject string
h := auth.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, ok := CallerFromContext(r.Context())
if !ok {
t.Fatal("missing caller")
}
gotSubject = c.Subject
gotRaw, ok = BearerFromContext(r.Context())
if !ok {
t.Fatal("missing bearer")
}
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "/v1/lists", nil)
req.Header.Set("Authorization", "Bearer "+raw)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d", rec.Code)
}
if gotSubject != "konrad" || gotRaw != raw {
t.Fatalf("subject %q raw match %v", gotSubject, gotRaw == raw)
}
health := httptest.NewRequest(http.MethodGet, "/health", nil)
hrec := httptest.NewRecorder()
auth.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, ok := CallerFromContext(r.Context()); ok {
t.Fatal("health has a caller")
}
w.WriteHeader(http.StatusOK)
})).ServeHTTP(hrec, health)
if hrec.Code != http.StatusOK {
t.Fatalf("health status = %d", hrec.Code)
}
bad := httptest.NewRequest(http.MethodGet, "/v1/lists", nil)
brec := httptest.NewRecorder()
h.ServeHTTP(brec, bad)
if brec.Code != http.StatusUnauthorized {
t.Fatalf("missing bearer status = %d", brec.Code)
}
}
func TestFromEnvModes(t *testing.T) {
key := testKey(t)
dir := t.TempDir()
pubPath := filepath.Join(dir, "user-token.pub")
privPath := filepath.Join(dir, "user-token.key")
pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
if err != nil {
t.Fatal(err)
}
writePEMBytes(t, pubPath, "PUBLIC KEY", pubDER)
writePEMBytes(t, privPath, "RSA PRIVATE KEY", x509.MarshalPKCS1PrivateKey(key))
t.Run("neither", func(t *testing.T) {
t.Setenv("USER_TOKEN_PUBLIC_KEY_FILE", "")
t.Setenv("OIDC_ISSUER", "")
if _, err := FromEnv(context.Background()); err == nil {
t.Fatal("expected error")
}
})
t.Run("both", func(t *testing.T) {
t.Setenv("USER_TOKEN_PUBLIC_KEY_FILE", pubPath)
t.Setenv("OIDC_ISSUER", "http://issuer.example")
if _, err := FromEnv(context.Background()); err == nil {
t.Fatal("expected error")
}
})
t.Run("static", func(t *testing.T) {
t.Setenv("USER_TOKEN_PUBLIC_KEY_FILE", pubPath)
t.Setenv("OIDC_ISSUER", "")
t.Setenv("USER_TOKEN_ISSUER", "manager")
t.Setenv("USER_TOKEN_AUDIENCE", "mgmnt")
auth, err := FromEnv(t.Context())
if err != nil {
t.Fatalf("FromEnv: %v", err)
}
t.Setenv("USER_TOKEN_PRIVATE_KEY_FILE", privPath)
signer, err := NewSignerFromEnv()
if err != nil {
t.Fatalf("NewSignerFromEnv: %v", err)
}
raw, err := signer.Mint(time.Now(), MintInput{Subject: "konrad"})
if err != nil {
t.Fatal(err)
}
c, err := auth.Verify(t.Context(), raw)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if c.Subject != "konrad" {
t.Fatalf("subject = %q", c.Subject)
}
})
}
func signClaims(t *testing.T, key *rsa.PrivateKey, claims tokenClaims) string {
t.Helper()
s, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(key)
if err != nil {
t.Fatalf("sign: %v", err)
}
return s
}
func decodePayload(t *testing.T, raw string) string {
t.Helper()
parts := splitToken(t, raw)
b, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
t.Fatalf("payload: %v", err)
}
return string(b)
}
func splitToken(t *testing.T, raw string) []string {
t.Helper()
var parts []string
start := 0
for i := 0; i <= len(raw); i++ {
if i == len(raw) || raw[i] == '.' {
parts = append(parts, raw[start:i])
start = i + 1
}
}
if len(parts) != 3 {
t.Fatalf("token parts = %d", len(parts))
}
return parts
}
func writePEMBytes(t *testing.T, path, typ string, der []byte) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := pem.Encode(f, &pem.Block{Type: typ, Bytes: der}); err != nil {
t.Fatal(err)
}
}
+48
View File
@@ -0,0 +1,48 @@
package usertoken
import (
"context"
"errors"
)
// ErrUnauthorized is wrapped by every failed token check.
// Startup and configuration failures do not wrap it.
var ErrUnauthorized = errors.New("unauthorized")
// Caller is the user a verified token names.
// Groups is nil when the token omitted the claim, and non-nil (possibly empty)
// when the claim was present.
type Caller struct {
Subject string
Username string
Groups []string
}
type ctxKey int
const (
ctxCaller ctxKey = iota
ctxBearer
)
// CallerFromContext returns the user [Auth.Middleware] stored.
func CallerFromContext(ctx context.Context) (Caller, bool) {
c, ok := ctx.Value(ctxCaller).(Caller)
return c, ok
}
// BearerFromContext returns the raw JWT [Auth.Middleware] stored, without the
// "Bearer " prefix. Outbound calls send "Bearer " plus this string.
func BearerFromContext(ctx context.Context) (string, bool) {
s, ok := ctx.Value(ctxBearer).(string)
return s, ok
}
func withAuth(ctx context.Context, c Caller, raw string) context.Context {
ctx = context.WithValue(ctx, ctxCaller, c)
return context.WithValue(ctx, ctxBearer, raw)
}
func unauthorized(msg string) error {
return errors.Join(errors.New(msg), ErrUnauthorized)
}
+10
View File
@@ -0,0 +1,10 @@
// Package usertoken mints and checks the short-lived RS256 user token an ExApp
// sends to Microservices.
//
// An ExApp calls [NewSignerFromEnv] and [Signer.Mint] after AppAPI has named
// the user. A Microservice calls [FromEnv] and [Auth.Middleware]. One process
// trusts either a static public key or an OIDC issuer, not both.
//
// The procedure, claims, and environment variables are in
// knowledge/platforms/nextcloud/exapps/authentication.md.
package usertoken
+14
View File
@@ -0,0 +1,14 @@
module gitea.neitzel.de/konrad/go-usertoken
go 1.27.0
require (
github.com/coreos/go-oidc/v3 v3.14.1
github.com/golang-jwt/jwt/v5 v5.2.2
)
require (
github.com/go-jose/go-jose/v4 v4.0.5 // indirect
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/oauth2 v0.28.0 // indirect
)
+20
View File
@@ -0,0 +1,20 @@
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+63
View File
@@ -0,0 +1,63 @@
package usertoken
import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
)
func readPrivateKeyFile(path string) (*rsa.PrivateKey, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read private key: %w", err)
}
return parsePrivateKey(b)
}
func readPublicKeyFile(path string) (*rsa.PublicKey, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read public key: %w", err)
}
return parsePublicKey(b)
}
func parsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, fmt.Errorf("private key pem")
}
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return key, nil
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
key, ok := parsed.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("private key is not RSA")
}
return key, nil
}
func parsePublicKey(pemBytes []byte) (*rsa.PublicKey, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, fmt.Errorf("public key pem")
}
if key, err := x509.ParsePKCS1PublicKey(block.Bytes); err == nil {
return key, nil
}
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse public key: %w", err)
}
key, ok := parsed.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("public key is not RSA")
}
return key, nil
}
+124
View File
@@ -0,0 +1,124 @@
package usertoken
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
)
type oidcVerifier struct {
verifier *oidc.IDTokenVerifier
groupsClaim string
skew time.Duration
}
func newOIDCVerifier(ctx context.Context, issuer, audience, groupsClaim string, skew time.Duration) (tokenVerifier, error) {
issuer = strings.TrimSpace(issuer)
if issuer == "" {
return nil, fmt.Errorf("OIDC issuer is empty")
}
if skew <= 0 {
skew = defaultSkew
}
groupsClaim = strings.TrimSpace(groupsClaim)
if groupsClaim == "" {
groupsClaim = "groups"
}
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return nil, fmt.Errorf("oidc discovery: %w", err)
}
aud := strings.TrimSpace(audience)
verifier := provider.Verifier(&oidc.Config{
ClientID: aud,
SkipClientIDCheck: aud == "",
SupportedSigningAlgs: []string{"RS256"},
Now: func() time.Time {
return time.Now().Add(-skew)
},
})
return &oidcVerifier{verifier: verifier, groupsClaim: groupsClaim, skew: skew}, nil
}
func (v *oidcVerifier) verify(ctx context.Context, raw string) (Caller, error) {
if strings.TrimSpace(raw) == "" {
return Caller{}, unauthorized("empty token")
}
tok, err := v.verifier.Verify(ctx, raw)
if err != nil {
return Caller{}, fmt.Errorf("oidc verify: %w", errors.Join(err, ErrUnauthorized))
}
var claims map[string]any
if err := tok.Claims(&claims); err != nil {
return Caller{}, unauthorized("claims")
}
now := time.Now()
if err := checkTimeClaim(claims, "iat", now, v.skew); err != nil {
return Caller{}, err
}
if err := checkTimeClaim(claims, "nbf", now, v.skew); err != nil {
return Caller{}, err
}
sub, _ := claims["sub"].(string)
username, _ := claims["preferred_username"].(string)
groups, err := groupsFromMap(claims, v.groupsClaim)
if err != nil {
return Caller{}, err
}
return callerFromClaims(sub, username, groups)
}
// checkTimeClaim rejects a numeric claim that is after now+skew.
// A missing claim is fine.
func checkTimeClaim(claims map[string]any, name string, now time.Time, skew time.Duration) error {
raw, ok := claims[name]
if !ok || raw == nil {
return nil
}
sec, ok := numericUnix(raw)
if !ok {
return unauthorized("bad " + name)
}
when := time.Unix(sec, 0)
if when.After(now.Add(skew)) {
return unauthorized(name + " in the future")
}
return nil
}
func numericUnix(v any) (int64, bool) {
switch n := v.(type) {
case float64:
return int64(n), true
case json.Number:
i, err := n.Int64()
return i, err == nil
default:
return 0, false
}
}
func groupsFromMap(claims map[string]any, name string) (*[]string, error) {
raw, ok := claims[name]
if !ok || raw == nil {
return nil, nil
}
arr, ok := raw.([]any)
if !ok {
return nil, unauthorized("groups claim")
}
out := make([]string, 0, len(arr))
for _, item := range arr {
s, ok := item.(string)
if !ok {
return nil, unauthorized("groups claim")
}
out = append(out, s)
}
return &out, nil
}
+87
View File
@@ -0,0 +1,87 @@
package usertoken
import (
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"math/big"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func TestOIDCVerifier(t *testing.T) {
key := testKey(t)
var srv *httptest.Server
mux := http.NewServeMux()
srv = httptest.NewServer(mux)
t.Cleanup(srv.Close)
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": srv.URL,
"jwks_uri": srv.URL + "/jwks",
"id_token_signing_alg_values_supported": []string{"RS256"},
})
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(publicJWKS(&key.PublicKey))
})
auth, err := New(t.Context(), Config{
OIDCIssuer: srv.URL,
OIDCAudience: "mgmnt",
GroupsClaim: "identity_groups",
})
if err != nil {
t.Fatalf("New: %v", err)
}
now := time.Now()
groups := []string{"family"}
raw, err := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
"iss": srv.URL,
"sub": "konrad",
"preferred_username": "konrad",
"aud": "mgmnt",
"iat": now.Unix(),
"exp": now.Add(time.Minute).Unix(),
"identity_groups": groups,
}).SignedString(key)
if err != nil {
t.Fatal(err)
}
caller, err := auth.Verify(t.Context(), raw)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if caller.Subject != "konrad" || len(caller.Groups) != 1 || caller.Groups[0] != "family" {
t.Fatalf("caller = %+v", caller)
}
if _, err := New(t.Context(), Config{
PublicKey: &key.PublicKey,
Issuer: "manager",
Audience: "mgmnt",
OIDCIssuer: srv.URL,
}); err == nil {
t.Fatal("both modes accepted")
}
if _, err := auth.Verify(t.Context(), "not-a-token"); !errors.Is(err, ErrUnauthorized) {
t.Fatalf("bad token err = %v", err)
}
}
func publicJWKS(pub *rsa.PublicKey) map[string]any {
return map[string]any{
"keys": []map[string]string{{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
}},
}
}
+108
View File
@@ -0,0 +1,108 @@
package usertoken
import (
"crypto/rsa"
"fmt"
"os"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
const defaultTTL = 5 * time.Minute
// SignConfig is the ExApp mint configuration.
// TTL of zero uses 5 minutes.
type SignConfig struct {
PrivateKey *rsa.PrivateKey
Issuer string
Audience string
TTL time.Duration
}
// MintInput is one user token.
// Groups nil omits the claim. A non-nil pointer includes it, including an empty list.
type MintInput struct {
Subject string
Groups *[]string
}
// Signer mints RS256 user tokens. Only the ExApp should hold one.
type Signer struct {
key *rsa.PrivateKey
iss string
aud string
ttl time.Duration
}
// NewSignerFromEnv reads USER_TOKEN_PRIVATE_KEY_FILE, USER_TOKEN_ISSUER,
// USER_TOKEN_AUDIENCE, and optional USER_TOKEN_TTL.
func NewSignerFromEnv() (*Signer, error) {
path := strings.TrimSpace(os.Getenv("USER_TOKEN_PRIVATE_KEY_FILE"))
if path == "" {
return nil, fmt.Errorf("USER_TOKEN_PRIVATE_KEY_FILE is empty")
}
key, err := readPrivateKeyFile(path)
if err != nil {
return nil, err
}
var ttl time.Duration
if raw := strings.TrimSpace(os.Getenv("USER_TOKEN_TTL")); raw != "" {
ttl, err = time.ParseDuration(raw)
if err != nil {
return nil, fmt.Errorf("USER_TOKEN_TTL: %w", err)
}
}
return NewSigner(SignConfig{
PrivateKey: key,
Issuer: os.Getenv("USER_TOKEN_ISSUER"),
Audience: os.Getenv("USER_TOKEN_AUDIENCE"),
TTL: ttl,
})
}
// NewSigner checks the key, issuer, and audience.
func NewSigner(cfg SignConfig) (*Signer, error) {
if cfg.PrivateKey == nil {
return nil, fmt.Errorf("private key is nil")
}
iss := strings.TrimSpace(cfg.Issuer)
aud := strings.TrimSpace(cfg.Audience)
if iss == "" || aud == "" {
return nil, fmt.Errorf("issuer and audience are required")
}
ttl := cfg.TTL
if ttl <= 0 {
ttl = defaultTTL
}
return &Signer{key: cfg.PrivateKey, iss: iss, aud: aud, ttl: ttl}, nil
}
type tokenClaims struct {
jwt.RegisteredClaims
PreferredUsername string `json:"preferred_username"`
Groups *[]string `json:"groups,omitempty"`
}
// Mint signs one token at now. Subject becomes sub and preferred_username.
func (s *Signer) Mint(now time.Time, in MintInput) (string, error) {
subject := strings.TrimSpace(in.Subject)
if subject == "" {
return "", fmt.Errorf("empty subject")
}
claims := tokenClaims{
Issuer: s.iss,
Subject: subject,
Audience: jwt.ClaimStrings{s.aud},
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(s.ttl)),
PreferredUsername: subject,
Groups: in.Groups,
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(s.key)
if err != nil {
return "", fmt.Errorf("sign: %w", err)
}
return signed, nil
}
+84
View File
@@ -0,0 +1,84 @@
package usertoken
import (
"context"
"crypto/rsa"
"errors"
"fmt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
const defaultSkew = time.Minute
type tokenVerifier interface {
verify(ctx context.Context, raw string) (Caller, error)
}
type staticVerifier struct {
key *rsa.PublicKey
iss string
aud string
skew time.Duration
}
func newStaticVerifier(key *rsa.PublicKey, iss, aud string, skew time.Duration) (tokenVerifier, error) {
if key == nil {
return nil, fmt.Errorf("public key is nil")
}
iss = strings.TrimSpace(iss)
aud = strings.TrimSpace(aud)
if iss == "" || aud == "" {
return nil, fmt.Errorf("issuer and audience are required")
}
if skew <= 0 {
skew = defaultSkew
}
return &staticVerifier{key: key, iss: iss, aud: aud, skew: skew}, nil
}
func (v *staticVerifier) verify(_ context.Context, raw string) (Caller, error) {
if strings.TrimSpace(raw) == "" {
return Caller{}, unauthorized("empty token")
}
parser := jwt.NewParser(
jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}),
jwt.WithIssuer(v.iss),
jwt.WithAudience(v.aud),
jwt.WithLeeway(v.skew),
jwt.WithExpirationRequired(),
jwt.WithIssuedAt(),
)
var claims tokenClaims
tok, err := parser.ParseWithClaims(raw, &claims, func(t *jwt.Token) (any, error) {
if t.Method == nil || t.Method.Alg() != jwt.SigningMethodRS256.Alg() {
return nil, unauthorized("unexpected alg")
}
return v.key, nil
})
if err != nil || tok == nil || !tok.Valid {
if err == nil {
err = errors.New("invalid token")
}
return Caller{}, fmt.Errorf("verify: %w", errors.Join(err, ErrUnauthorized))
}
return callerFromClaims(claims.Subject, claims.PreferredUsername, claims.Groups)
}
func callerFromClaims(subject, username string, groups *[]string) (Caller, error) {
subject = strings.TrimSpace(subject)
if subject == "" {
return Caller{}, unauthorized("empty sub")
}
username = strings.TrimSpace(username)
if username == "" {
username = subject
}
c := Caller{Subject: subject, Username: username}
if groups != nil {
c.Groups = append([]string{}, (*groups)...)
}
return c, nil
}