diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..6df3387 --- /dev/null +++ b/CONTEXT.md @@ -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 diff --git a/README.md b/README.md index d65493a..9526fcd 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..011a4f3 --- /dev/null +++ b/auth.go @@ -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 +} diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..28dfba1 --- /dev/null +++ b/auth_test.go @@ -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) + } +} diff --git a/caller.go b/caller.go new file mode 100644 index 0000000..30d97fa --- /dev/null +++ b/caller.go @@ -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) +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..1160cc2 --- /dev/null +++ b/doc.go @@ -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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8a8862e --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..122417f --- /dev/null +++ b/go.sum @@ -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= diff --git a/keys.go b/keys.go new file mode 100644 index 0000000..2d5a1d3 --- /dev/null +++ b/keys.go @@ -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 +} diff --git a/oidc.go b/oidc.go new file mode 100644 index 0000000..599dd6e --- /dev/null +++ b/oidc.go @@ -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 +} diff --git a/oidc_test.go b/oidc_test.go new file mode 100644 index 0000000..5a6d1cd --- /dev/null +++ b/oidc_test.go @@ -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()), + }}, + } +} diff --git a/signer.go b/signer.go new file mode 100644 index 0000000..82ec637 --- /dev/null +++ b/signer.go @@ -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 +} diff --git a/verify.go b/verify.go new file mode 100644 index 0000000..01b1253 --- /dev/null +++ b/verify.go @@ -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 +}