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:
+25
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()),
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user