Compare commits

...
3 Commits
Author SHA1 Message Date
konrad cd95a5cd9a Updated go.mod/go.sum 2026-10-16 16:18:44 +02:00
konrad 1935848723 Updated go.mod/go.sum 2026-10-16 16:16:37 +02:00
konradandCursor b4281445cc Document every exported symbol and how callers use the library.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 19:25:45 +02:00
8 changed files with 657 additions and 73 deletions
+21 -49
View File
@@ -4,66 +4,38 @@ Short-lived RS256 user tokens from a Nextcloud ExApp to Go Microservices.
Import: `gitea.neitzel.de/konrad/go-usertoken` (package `usertoken`). Import: `gitea.neitzel.de/konrad/go-usertoken` (package `usertoken`).
```bash
go get gitea.neitzel.de/konrad/go-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. 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). - [Guide](docs/guide.md) — how to mint and how to verify
- [API](docs/api.md) — every exported symbol
- [Domain language](CONTEXT.md)
This module does not speak AppAPI and does not see `APP_SECRET`. Procedure and claim rules: Knowledge `platforms/nextcloud/exapps/authentication.md`.
## ExApp ## Included
```go - [ExApp](docs/guide.md#exapp) — mint a token, including key generation
signer, err := usertoken.NewSignerFromEnv() - [Microservice](docs/guide.md#microservice) — verify a bearer and forward it
raw, err := signer.Mint(time.Now(), usertoken.MintInput{
Subject: userID,
Groups: &groupIDs, // nil omits groups
})
req.Header.Set("Authorization", "Bearer "+raw)
```
| Variable | Role | ## Excluded
| --- | --- |
| `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 - AppAPI and `APP_SECRET`
- Choosing the Nextcloud user (the ExApp already has that id)
```go ## Testing
auth, err := usertoken.FromEnv(ctx)
handler = auth.Middleware()(handler)
caller, _ := usertoken.CallerFromContext(r.Context()) Unit tests cover minting, static verification, and the HTTP and gRPC interceptors. OIDC tests do not need a live issuer unless a test starts one.
raw, _ := usertoken.BearerFromContext(r.Context())
out.Header.Set("Authorization", "Bearer "+raw)
```
`/health` is not authenticated. Any other path without a valid bearer is 401. `go test ./...` fails when [`docs/api.md`](docs/api.md) does not match the exported API. Regenerate it with:
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 ```bash
openssl genrsa -out user-token.key 2048 UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1
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. ## Related
- Knowledge `platforms/nextcloud/exapps/authentication.md` — procedure and claims
+278
View File
@@ -0,0 +1,278 @@
package usertoken_test
import (
"bytes"
"fmt"
"go/ast"
"go/doc"
"go/doc/comment"
"go/parser"
"go/printer"
"go/token"
"os"
"strings"
"testing"
)
const apiModulePath = "gitea.neitzel.de/konrad/go-usertoken"
func TestAPIDoc(t *testing.T) {
got, err := renderAPIDoc(".", apiModulePath)
if err != nil {
t.Fatal(err)
}
const path = "docs/api.md"
if os.Getenv("UPDATE_API_DOCS") == "1" {
if err := os.MkdirAll("docs", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(got), 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
if string(want) != got {
t.Fatalf("docs/api.md is stale; regenerate with UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1\n%s", firstDiff(string(want), got))
}
}
func renderAPIDoc(dir, modulePath string) (string, error) {
fset := token.NewFileSet()
pkgs, err := parser.ParseDir(fset, dir, func(fi os.FileInfo) bool {
return !strings.HasSuffix(fi.Name(), "_test.go")
}, parser.ParseComments)
if err != nil {
return "", err
}
if len(pkgs) != 1 {
return "", fmt.Errorf("expected 1 package in %s, found %d", dir, len(pkgs))
}
var astPkg *ast.Package
for _, p := range pkgs {
astPkg = p
}
pkg := doc.New(astPkg, modulePath, 0)
lookup := symLookup(pkg)
var b strings.Builder
b.WriteString("# API\n\n")
b.WriteString("Generated from the package comment and every exported declaration. Do not edit.\n")
b.WriteString("Regenerate with `UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1`.\n\n")
fmt.Fprintf(&b, "## Package %s\n\n", pkg.Name)
writeDoc(&b, pkg.Doc, lookup)
writeValues(&b, fset, "Constants", pkg.Consts, "### ", lookup)
writeValues(&b, fset, "Variables", pkg.Vars, "### ", lookup)
writeFuncs(&b, fset, "Functions", pkg.Funcs, "### ", lookup)
if len(pkg.Types) > 0 {
b.WriteString("## Types\n\n")
for _, typ := range pkg.Types {
writeType(&b, fset, typ, lookup)
}
}
return b.String(), nil
}
func writeType(b *strings.Builder, fset *token.FileSet, typ *doc.Type, lookup func(string, string) bool) {
fmt.Fprintf(b, "### %s\n\n", typ.Name)
sig, err := formatGenDecl(fset, typ.Decl)
if err != nil {
fmt.Fprintf(b, "_(signature unavailable: %v)_\n\n", err)
} else {
writeCode(b, sig)
}
writeDoc(b, typ.Doc, lookup)
writeValues(b, fset, "", typ.Consts, "#### ", lookup)
writeValues(b, fset, "", typ.Vars, "#### ", lookup)
writeFuncs(b, fset, "", typ.Funcs, "#### ", lookup)
if !isInterface(typ) {
writeFuncs(b, fset, "", typ.Methods, "#### ", lookup)
}
}
func writeValues(b *strings.Builder, fset *token.FileSet, title string, vals []*doc.Value, heading string, lookup func(string, string) bool) {
if len(vals) == 0 {
return
}
if title != "" {
fmt.Fprintf(b, "## %s\n\n", title)
}
for _, v := range vals {
for _, name := range v.Names {
fmt.Fprintf(b, "%s%s\n\n", heading, name)
}
sig, err := formatGenDecl(fset, v.Decl)
if err != nil {
fmt.Fprintf(b, "_(signature unavailable: %v)_\n\n", err)
} else {
writeCode(b, sig)
}
writeDoc(b, v.Doc, lookup)
}
}
func writeFuncs(b *strings.Builder, fset *token.FileSet, title string, fns []*doc.Func, heading string, lookup func(string, string) bool) {
if len(fns) == 0 {
return
}
if title != "" {
fmt.Fprintf(b, "## %s\n\n", title)
}
for _, fn := range fns {
name := fn.Name
if fn.Recv != "" {
name = strings.TrimPrefix(fn.Recv, "*") + "." + fn.Name
}
fmt.Fprintf(b, "%s%s\n\n", heading, name)
sig, err := formatFunc(fset, fn.Decl)
if err != nil {
fmt.Fprintf(b, "_(signature unavailable: %v)_\n\n", err)
} else {
writeCode(b, sig)
}
writeDoc(b, fn.Doc, lookup)
}
}
func writeCode(b *strings.Builder, sig string) {
b.WriteString("```go\n")
b.WriteString(sig)
b.WriteString("\n```\n\n")
}
func writeDoc(b *strings.Builder, raw string, lookup func(string, string) bool) {
text := renderComment(raw, lookup)
if text == "" {
return
}
b.WriteString(text)
if !strings.HasSuffix(text, "\n") {
b.WriteByte('\n')
}
b.WriteByte('\n')
}
func symLookup(pkg *doc.Package) func(string, string) bool {
syms := map[string]bool{}
methods := map[string]bool{}
addNames := func(vals []*doc.Value) {
for _, v := range vals {
for _, name := range v.Names {
syms[name] = true
}
}
}
addFuncs := func(fns []*doc.Func) {
for _, fn := range fns {
syms[fn.Name] = true
if fn.Recv != "" {
recv := strings.TrimPrefix(fn.Recv, "*")
methods[recv+"."+fn.Name] = true
}
}
}
addNames(pkg.Consts)
addNames(pkg.Vars)
addFuncs(pkg.Funcs)
for _, typ := range pkg.Types {
syms[typ.Name] = true
addNames(typ.Consts)
addNames(typ.Vars)
addFuncs(typ.Funcs)
addFuncs(typ.Methods)
}
return func(recv, name string) bool {
if recv == "" {
return syms[name]
}
return methods[recv+"."+name]
}
}
func renderComment(raw string, lookup func(string, string) bool) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
var parser comment.Parser
parser.LookupSym = lookup
var printer comment.Printer
printer.HeadingLevel = 4
printer.DocLinkURL = func(link *comment.DocLink) string {
if link.ImportPath != "" {
return link.DefaultURL("https://pkg.go.dev")
}
name := link.Name
if link.Recv != "" {
name = link.Recv + "." + link.Name
}
return "#" + githubAnchor(name)
}
return strings.TrimSpace(string(printer.Markdown(parser.Parse(raw))))
}
// githubAnchor matches GitHub heading slugs: lowercase, drop punctuation, keep
// letters, digits, hyphens, and underscores. "Client.Chat" becomes "clientchat".
func githubAnchor(name string) string {
var b strings.Builder
for _, r := range strings.ToLower(name) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
b.WriteRune(r)
}
}
return b.String()
}
func formatGenDecl(fset *token.FileSet, decl *ast.GenDecl) (string, error) {
if decl == nil {
return "", fmt.Errorf("nil decl")
}
copyDecl := *decl
copyDecl.Doc = nil
return formatNode(fset, &copyDecl)
}
func formatFunc(fset *token.FileSet, decl *ast.FuncDecl) (string, error) {
if decl == nil {
return "", fmt.Errorf("nil decl")
}
copyDecl := *decl
copyDecl.Doc = nil
copyDecl.Body = nil
return formatNode(fset, &copyDecl)
}
func formatNode(fset *token.FileSet, node ast.Node) (string, error) {
var buf bytes.Buffer
if err := printer.Fprint(&buf, fset, node); err != nil {
return "", err
}
return strings.TrimSpace(buf.String()), nil
}
func isInterface(typ *doc.Type) bool {
if typ.Decl == nil || len(typ.Decl.Specs) != 1 {
return false
}
spec, ok := typ.Decl.Specs[0].(*ast.TypeSpec)
if !ok {
return false
}
_, ok = spec.Type.(*ast.InterfaceType)
return ok
}
func firstDiff(want, got string) string {
w := strings.Split(want, "\n")
g := strings.Split(got, "\n")
n := min(len(g), len(w))
for i := 0; i < n; i++ {
if w[i] != g[i] {
return fmt.Sprintf("line %d:\n got: %s\nwant: %s", i+1, g[i], w[i])
}
}
return fmt.Sprintf("length got %d lines, want %d lines", len(g), len(w))
}
+11 -3
View File
@@ -29,8 +29,14 @@ type Auth struct {
v tokenVerifier v tokenVerifier
} }
// FromEnv reads the process environment. See the authentication procedure for names. // FromEnv reads the process environment and builds an Auth.
// Both USER_TOKEN_PUBLIC_KEY_FILE and OIDC_ISSUER set, or neither set, is an error. // Set USER_TOKEN_PUBLIC_KEY_FILE or OIDC_ISSUER, not both and not neither.
// Static mode also reads USER_TOKEN_ISSUER and USER_TOKEN_AUDIENCE.
// OIDC mode reads OIDC_AUDIENCE and optional OIDC_GROUPS_CLAIM.
// Optional USER_TOKEN_SKEW is a time.Duration; empty uses 1 minute.
// It returns an error when the mode is ambiguous, the key file is unreadable,
// USER_TOKEN_SKEW cannot be parsed, or OIDC discovery fails.
// ctx is used for OIDC discovery.
func FromEnv(ctx context.Context) (*Auth, error) { func FromEnv(ctx context.Context) (*Auth, error) {
pubFile := strings.TrimSpace(os.Getenv("USER_TOKEN_PUBLIC_KEY_FILE")) pubFile := strings.TrimSpace(os.Getenv("USER_TOKEN_PUBLIC_KEY_FILE"))
oidcIss := strings.TrimSpace(os.Getenv("OIDC_ISSUER")) oidcIss := strings.TrimSpace(os.Getenv("OIDC_ISSUER"))
@@ -89,7 +95,9 @@ func New(ctx context.Context, cfg Config) (*Auth, error) {
return &Auth{v: v}, nil return &Auth{v: v}, nil
} }
// Verify checks a raw JWT (no "Bearer " prefix). // Verify checks a raw JWT. raw has no "Bearer " prefix.
// A failed check wraps ErrUnauthorized.
// Startup and configuration failures do not.
func (a *Auth) Verify(ctx context.Context, raw string) (Caller, error) { func (a *Auth) Verify(ctx context.Context, raw string) (Caller, error) {
return a.v.verify(ctx, raw) return a.v.verify(ctx, raw)
} }
+179
View File
@@ -0,0 +1,179 @@
# API
Generated from the package comment and every exported declaration. Do not edit.
Regenerate with `UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1`.
## Package usertoken
Package usertoken mints and checks the short-lived RS256 user token an ExApp sends to Microservices.
An ExApp calls [NewSignerFromEnv](#newsignerfromenv) and [Signer.Mint](#signermint) after AppAPI has named the user. A Microservice calls [FromEnv](#fromenv) and [Auth.Middleware](#authmiddleware) or [Auth.UnaryServerInterceptor](#authunaryserverinterceptor). 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.
## Variables
### ErrUnauthorized
```go
var ErrUnauthorized = errors.New("unauthorized")
```
ErrUnauthorized is wrapped by every failed token check. Startup and configuration failures do not wrap it.
## Functions
### BearerFromContext
```go
func BearerFromContext(ctx context.Context) (string, bool)
```
BearerFromContext returns the raw JWT [Auth.Middleware](#authmiddleware) stored, without the "Bearer " prefix. Outbound calls send "Bearer " plus this string.
## Types
### Auth
```go
type Auth struct {
// contains filtered or unexported fields
}
```
Auth verifies bearers for one Microservice process.
#### FromEnv
```go
func FromEnv(ctx context.Context) (*Auth, error)
```
FromEnv reads the process environment and builds an Auth. Set USER\_TOKEN\_PUBLIC\_KEY\_FILE or OIDC\_ISSUER, not both and not neither. Static mode also reads USER\_TOKEN\_ISSUER and USER\_TOKEN\_AUDIENCE. OIDC mode reads OIDC\_AUDIENCE and optional OIDC\_GROUPS\_CLAIM. Optional USER\_TOKEN\_SKEW is a time.Duration; empty uses 1 minute. It returns an error when the mode is ambiguous, the key file is unreadable, USER\_TOKEN\_SKEW cannot be parsed, or OIDC discovery fails. ctx is used for OIDC discovery.
#### New
```go
func New(ctx context.Context, cfg Config) (*Auth, error)
```
New builds an Auth from explicit configuration. ctx is used for OIDC discovery.
#### Auth.Middleware
```go
func (a *Auth) Middleware() func(http.Handler) http.Handler
```
Middleware requires a bearer on every path except /health. Success stores the Caller and the raw JWT on the request context.
#### Auth.UnaryServerInterceptor
```go
func (a *Auth) UnaryServerInterceptor() grpc.UnaryServerInterceptor
```
UnaryServerInterceptor requires a bearer on every RPC. Success stores the Caller and the raw JWT on the handler context, same as [Auth.Middleware](#authmiddleware). Native clients send metadata key "authorization". The HTTP gateway's forwarded header is "grpcgateway-authorization".
#### Auth.Verify
```go
func (a *Auth) Verify(ctx context.Context, raw string) (Caller, error)
```
Verify checks a raw JWT. raw has no "Bearer " prefix. A failed check wraps ErrUnauthorized. Startup and configuration failures do not.
### Caller
```go
type Caller struct {
Subject string
Username string
Groups []string
}
```
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.
#### CallerFromContext
```go
func CallerFromContext(ctx context.Context) (Caller, bool)
```
CallerFromContext returns the user [Auth.Middleware](#authmiddleware) stored.
### Config
```go
type Config struct {
PublicKey *rsa.PublicKey
Issuer string
Audience string
OIDCIssuer string
OIDCAudience string
GroupsClaim string
Skew time.Duration
}
```
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.
### MintInput
```go
type MintInput struct {
Subject string
Groups *[]string
}
```
MintInput is one user token. Groups nil omits the claim. A non-nil pointer includes it, including an empty list.
### SignConfig
```go
type SignConfig struct {
PrivateKey *rsa.PrivateKey
Issuer string
Audience string
TTL time.Duration
}
```
SignConfig is the ExApp mint configuration. TTL of zero uses 5 minutes.
### Signer
```go
type Signer struct {
// contains filtered or unexported fields
}
```
Signer mints RS256 user tokens. Only the ExApp should hold one.
#### NewSigner
```go
func NewSigner(cfg SignConfig) (*Signer, error)
```
NewSigner checks the key, issuer, and audience. It returns an error when PrivateKey is nil or issuer or audience is empty. A TTL of zero or less uses 5 minutes.
#### NewSignerFromEnv
```go
func NewSignerFromEnv() (*Signer, error)
```
NewSignerFromEnv reads USER\_TOKEN\_PRIVATE\_KEY\_FILE, USER\_TOKEN\_ISSUER, USER\_TOKEN\_AUDIENCE, and optional USER\_TOKEN\_TTL. It returns an error when the private-key path is empty, the PEM is not an RSA key, USER\_TOKEN\_TTL is set and is not a time.Duration, or issuer or audience is empty. An empty USER\_TOKEN\_TTL uses 5 minutes.
#### Signer.Mint
```go
func (s *Signer) Mint(now time.Time, in MintInput) (string, error)
```
Mint signs one RS256 token at now. Subject becomes both sub and preferred\_username. It returns an error when Subject is empty or the key cannot sign.
+133
View File
@@ -0,0 +1,133 @@
# go-usertoken guide
How to use the library. Symbol signatures and doc comments are in [API](api.md). Words for the domain are in [CONTEXT.md](../CONTEXT.md). Claim layout and the full environment list are in Knowledge `platforms/nextcloud/exapps/authentication.md`.
## ExApp
### Purpose
The ExApp mints a short-lived RS256 token for the user AppAPI already named. Only the ExApp holds the private key. Microservices hold the matching public key, or they trust an OIDC issuer instead.
### When to use it
Mint a token when the ExApp calls a Microservice on behalf of the requesting user. Do not mint inside a Microservice.
### Call sequence
1. Generate an RSA key once and keep the private key on the ExApp. Copy `user-token.pub` to each Microservice that uses static mode.
```bash
openssl genrsa -out user-token.key 2048
openssl rsa -in user-token.key -pubout -out user-token.pub
```
2. Set the ExApp environment:
| Variable | Role |
| --- | --- |
| `USER_TOKEN_PRIVATE_KEY_FILE` | RSA private key PEM (PKCS#1 or PKCS#8) |
| `USER_TOKEN_ISSUER` | `iss` string |
| `USER_TOKEN_AUDIENCE` | `aud` string |
| `USER_TOKEN_TTL` | optional, default `5m` |
3. Call `NewSignerFromEnv` at startup, or `NewSigner` with a `SignConfig`.
4. After AppAPI has named the user, call `Signer.Mint` with the current time and a `MintInput`.
5. Send `Authorization: Bearer` plus the returned string.
`MintInput.Subject` becomes both `sub` and `preferred_username`. `Groups` nil omits the groups claim. A non-nil pointer includes the claim, including an empty list.
A `SignConfig.TTL` of zero uses 5 minutes, same as an empty `USER_TOKEN_TTL`.
### Errors
`NewSignerFromEnv` returns an error when the private-key path is empty, the PEM is not an RSA key, `USER_TOKEN_TTL` is set and is not a `time.Duration`, or issuer or audience is empty.
`NewSigner` returns an error when the private key is nil or issuer or audience is empty.
`Mint` returns an error when the subject is empty or the key cannot sign. Configuration failures are not `ErrUnauthorized`. That sentinel is for failed checks on the Microservice side.
### Example
```go
signer, err := usertoken.NewSignerFromEnv()
if err != nil {
return err
}
raw, err := signer.Mint(time.Now(), usertoken.MintInput{
Subject: userID,
Groups: &groupIDs, // nil omits groups
})
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+raw)
```
## Microservice
### Purpose
A Microservice checks the bearer and stores the caller on the request context. One process uses either a static public key or an OIDC issuer.
### When to use it
Wrap HTTP handlers, or install the gRPC interceptor, at process start. Forward the same raw JWT when this service calls another.
### Call sequence
1. Set exactly one mode.
Static public key (tokens this ExApp minted):
| 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:
| Variable | Role |
| --- | --- |
| `OIDC_ISSUER` | issuer URL that serves discovery |
| `OIDC_AUDIENCE` | expected `aud`; empty skips the audience check |
| `OIDC_GROUPS_CLAIM` | group array claim, default `groups` |
| `USER_TOKEN_SKEW` | optional, default `1m` |
2. Call `FromEnv` with a context. The context is used for OIDC discovery. Or call `New` with a `Config` that sets either `PublicKey` or `OIDCIssuer`.
3. Wrap handlers with `Auth.Middleware`. For gRPC, use `Auth.UnaryServerInterceptor`. Native clients send metadata `authorization`. The HTTP gateway's forwarded header is `grpcgateway-authorization`.
4. Inside a handler, read `CallerFromContext` and `BearerFromContext`.
5. On an outbound call, set `Authorization` to `Bearer ` plus the raw string from `BearerFromContext`.
`/health` is not authenticated. Any other HTTP path without a valid bearer is 401. A gRPC call without a valid bearer is `Unauthenticated`.
`Caller.Groups` is nil when the token omitted the claim, and non-nil (possibly empty) when the claim was present.
### Errors
`FromEnv` and `New` return an error when both modes are set, when neither mode is set, when the public key file is unreadable, when `USER_TOKEN_SKEW` cannot be parsed, or when OIDC discovery fails. Those are configuration errors. They do not wrap `ErrUnauthorized`.
`Verify` returns an error that wraps `ErrUnauthorized` when the token is missing, expired, signed by the wrong key, or has the wrong issuer or audience. `Middleware` and `UnaryServerInterceptor` turn that into 401 or `Unauthenticated` and do not call the handler.
An empty `OIDCAudience` skips the audience check. Static mode always requires an audience.
### Example
```go
auth, err := usertoken.FromEnv(ctx)
if err != nil {
return err
}
handler = auth.Middleware()(handler)
// inside a handler
caller, ok := usertoken.CallerFromContext(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
raw, _ := usertoken.BearerFromContext(r.Context())
out.Header.Set("Authorization", "Bearer "+raw)
_ = caller
```
+2 -3
View File
@@ -5,16 +5,15 @@ go 1.27.0
require ( require (
github.com/coreos/go-oidc/v3 v3.14.1 github.com/coreos/go-oidc/v3 v3.14.1
github.com/golang-jwt/jwt/v5 v5.2.2 github.com/golang-jwt/jwt/v5 v5.2.2
google.golang.org/grpc v1.81.1
) )
require ( require (
github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect golang.org/x/net v0.51.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260720155508-bb71a54f79dc // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )
+25 -17
View File
@@ -1,38 +1,46 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= 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/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/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 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/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+8 -1
View File
@@ -38,6 +38,9 @@ type Signer struct {
// NewSignerFromEnv reads USER_TOKEN_PRIVATE_KEY_FILE, USER_TOKEN_ISSUER, // NewSignerFromEnv reads USER_TOKEN_PRIVATE_KEY_FILE, USER_TOKEN_ISSUER,
// USER_TOKEN_AUDIENCE, and optional USER_TOKEN_TTL. // USER_TOKEN_AUDIENCE, and optional USER_TOKEN_TTL.
// It returns an error when the private-key path is empty, the PEM is not an
// RSA key, USER_TOKEN_TTL is set and is not a time.Duration, or issuer or
// audience is empty. An empty USER_TOKEN_TTL uses 5 minutes.
func NewSignerFromEnv() (*Signer, error) { func NewSignerFromEnv() (*Signer, error) {
path := strings.TrimSpace(os.Getenv("USER_TOKEN_PRIVATE_KEY_FILE")) path := strings.TrimSpace(os.Getenv("USER_TOKEN_PRIVATE_KEY_FILE"))
if path == "" { if path == "" {
@@ -63,6 +66,8 @@ func NewSignerFromEnv() (*Signer, error) {
} }
// NewSigner checks the key, issuer, and audience. // NewSigner checks the key, issuer, and audience.
// It returns an error when PrivateKey is nil or issuer or audience is empty.
// A TTL of zero or less uses 5 minutes.
func NewSigner(cfg SignConfig) (*Signer, error) { func NewSigner(cfg SignConfig) (*Signer, error) {
if cfg.PrivateKey == nil { if cfg.PrivateKey == nil {
return nil, fmt.Errorf("private key is nil") return nil, fmt.Errorf("private key is nil")
@@ -85,7 +90,9 @@ type tokenClaims struct {
Groups *[]string `json:"groups,omitempty"` Groups *[]string `json:"groups,omitempty"`
} }
// Mint signs one token at now. Subject becomes sub and preferred_username. // Mint signs one RS256 token at now.
// Subject becomes both sub and preferred_username.
// It returns an error when Subject is empty or the key cannot sign.
func (s *Signer) Mint(now time.Time, in MintInput) (string, error) { func (s *Signer) Mint(now time.Time, in MintInput) (string, error) {
subject := strings.TrimSpace(in.Subject) subject := strings.TrimSpace(in.Subject)
if subject == "" { if subject == "" {