From b4281445ccbd39dda53c2661a1b1746eded64595 Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Tue, 29 Sep 2026 19:25:45 +0200 Subject: [PATCH] Document every exported symbol and how callers use the library. Co-authored-by: Cursor --- README.md | 70 ++++--------- apidoc_test.go | 278 +++++++++++++++++++++++++++++++++++++++++++++++++ auth.go | 14 ++- docs/api.md | 179 +++++++++++++++++++++++++++++++ docs/guide.md | 133 +++++++++++++++++++++++ signer.go | 9 +- 6 files changed, 630 insertions(+), 53 deletions(-) create mode 100644 apidoc_test.go create mode 100644 docs/api.md create mode 100644 docs/guide.md diff --git a/README.md b/README.md index 9526fcd..8557db9 100644 --- a/README.md +++ b/README.md @@ -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`). +```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. -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 -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) -``` +- [ExApp](docs/guide.md#exapp) — mint a token, including key generation +- [Microservice](docs/guide.md#microservice) — verify a bearer and forward it -| 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` | +## Excluded -## Microservice +- AppAPI and `APP_SECRET` +- Choosing the Nextcloud user (the ExApp already has that id) -```go -auth, err := usertoken.FromEnv(ctx) -handler = auth.Middleware()(handler) +## Testing -caller, _ := usertoken.CallerFromContext(r.Context()) -raw, _ := usertoken.BearerFromContext(r.Context()) -out.Header.Set("Authorization", "Bearer "+raw) -``` +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. -`/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` | +`go test ./...` fails when [`docs/api.md`](docs/api.md) does not match the exported API. Regenerate it with: ```bash -openssl genrsa -out user-token.key 2048 -openssl rsa -in user-token.key -pubout -out user-token.pub +UPDATE_API_DOCS=1 go test -run TestAPIDoc -count=1 ``` -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 diff --git a/apidoc_test.go b/apidoc_test.go new file mode 100644 index 0000000..2c2a48b --- /dev/null +++ b/apidoc_test.go @@ -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, ©Decl) +} + +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, ©Decl) +} + +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)) +} diff --git a/auth.go b/auth.go index 011a4f3..1a3881a 100644 --- a/auth.go +++ b/auth.go @@ -29,8 +29,14 @@ 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. +// 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. func FromEnv(ctx context.Context) (*Auth, error) { pubFile := strings.TrimSpace(os.Getenv("USER_TOKEN_PUBLIC_KEY_FILE")) 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 } -// 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) { return a.v.verify(ctx, raw) } diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..91b2318 --- /dev/null +++ b/docs/api.md @@ -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. + diff --git a/docs/guide.md b/docs/guide.md new file mode 100644 index 0000000..982d3fc --- /dev/null +++ b/docs/guide.md @@ -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 +``` diff --git a/signer.go b/signer.go index 82ec637..ab783c2 100644 --- a/signer.go +++ b/signer.go @@ -38,6 +38,9 @@ type Signer struct { // 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. func NewSignerFromEnv() (*Signer, error) { path := strings.TrimSpace(os.Getenv("USER_TOKEN_PRIVATE_KEY_FILE")) if path == "" { @@ -63,6 +66,8 @@ func NewSignerFromEnv() (*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. func NewSigner(cfg SignConfig) (*Signer, error) { if cfg.PrivateKey == nil { return nil, fmt.Errorf("private key is nil") @@ -85,7 +90,9 @@ type tokenClaims struct { 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) { subject := strings.TrimSpace(in.Subject) if subject == "" {