This is an automated email from the ASF dual-hosted git repository. lahirujayathilake pushed a commit to branch auth in repository https://gitbox.apache.org/repos/asf/airavata-custos.git
commit 10931d25594a74c629ebc2ae4fd518581a5dc2cf Author: lahiruj <[email protected]> AuthorDate: Tue Jun 23 02:36:16 2026 -0400 Add OIDC identity middleware, JWT verifier, and privilege handling utilities --- go.mod | 3 + go.sum | 6 ++ pkg/identity/identity.go | 91 +++++++++++++++++++++++++++++++ pkg/identity/middleware.go | 133 +++++++++++++++++++++++++++++++++++++++++++++ pkg/identity/verifier.go | 57 +++++++++++++++++++ 5 files changed, 290 insertions(+) diff --git a/go.mod b/go.mod index 4b03baa37..24d13868a 100644 --- a/go.mod +++ b/go.mod @@ -22,8 +22,10 @@ require ( github.com/KyleBanks/depth v1.2.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coreos/go-oidc/v3 v3.19.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -45,6 +47,7 @@ require ( go.opentelemetry.io/otel/metric v1.43.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/mod v0.29.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.31.0 // indirect diff --git a/go.sum b/go.sum index bb2243ece..96b01fe44 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/coreos/go-oidc/v3 v3.19.0 h1:F/xyOi3x1UnG1U27YVnM1N6bHiL1K2upi6U/0qr8r+I= +github.com/coreos/go-oidc/v3 v3.19.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -37,6 +39,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +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-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -149,6 +153,8 @@ go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +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/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/pkg/identity/identity.go b/pkg/identity/identity.go new file mode 100644 index 000000000..2564392d7 --- /dev/null +++ b/pkg/identity/identity.go @@ -0,0 +1,91 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Package identity carries the verified caller across the request lifecycle. +// HTTP handlers reach for [CallerFromContext] and [HasPrivilege]; the auth +// middleware installs both after verifying the bearer token. +package identity + +import ( + "context" + "errors" + + "github.com/apache/airavata-custos/pkg/models" +) + +// Caller is the verified identity behind an HTTP request. +type Caller struct { + UserID string + OIDCSub string + Email string +} + +// ErrNotLinked is returned by a UserResolver when the OIDC sub has no row in +// the Custos user table. The middleware translates this into 401 +// identity_not_linked. +var ErrNotLinked = errors.New("identity not linked") + +// UserResolver maps a verified OIDC sub to a Caller and the effective +// privilege set. +type UserResolver interface { + ResolveCaller(ctx context.Context, oidcSub string) (*Caller, []models.PrivilegeKey, error) +} + +type ctxKey string + +const ( + callerKey ctxKey = "caller" + privilegesKey ctxKey = "privileges" +) + +type privilegeSet map[models.PrivilegeKey]struct{} + +// WithCaller attaches caller to ctx. +func WithCaller(ctx context.Context, caller *Caller) context.Context { + return context.WithValue(ctx, callerKey, caller) +} + +// CallerFromContext returns the verified caller, or nil if none is set. +func CallerFromContext(ctx context.Context) *Caller { + if caller, ok := ctx.Value(callerKey).(*Caller); ok { + return caller + } + return nil +} + +// withPrivileges attaches the caller's effective privilege set to ctx; +// RequirePrivilege wrappers only read it. +func withPrivileges(ctx context.Context, keys []models.PrivilegeKey) context.Context { + set := make(privilegeSet, len(keys)) + for _, key := range keys { + set[key] = struct{}{} + } + return context.WithValue(ctx, privilegesKey, set) +} + +// HasPrivilege reports whether privilege is present in the set attached to +// ctx. A context that never went through the middleware returns false. +func HasPrivilege(ctx context.Context, privilege models.PrivilegeKey) bool { + set, _ := ctx.Value(privilegesKey).(privilegeSet) + _, ok := set[privilege] + return ok +} + +// WithPrivilegesForTest is the test-only entry point to seed privileges on ctx. +func WithPrivilegesForTest(ctx context.Context, keys []models.PrivilegeKey) context.Context { + return withPrivileges(ctx, keys) +} diff --git a/pkg/identity/middleware.go b/pkg/identity/middleware.go new file mode 100644 index 000000000..9b42f099f --- /dev/null +++ b/pkg/identity/middleware.go @@ -0,0 +1,133 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package identity + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/apache/airavata-custos/pkg/models" +) + +// Middleware verifies the bearer token once, resolves the caller, and +// attaches Caller + privilege set to the request context. Public paths +// bypass verification entirely. +func Middleware(verifier *JWTVerifier, resolver UserResolver, publicPaths []string, next http.Handler) http.Handler { + publicSet := make(map[string]struct{}, len(publicPaths)) + for _, path := range publicPaths { + publicSet[path] = struct{}{} + } + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if _, ok := publicSet[req.URL.Path]; ok { + next.ServeHTTP(w, req) + return + } + rawToken, ok := bearer(req.Header.Get("Authorization")) + if !ok { + writeJSONError(w, http.StatusUnauthorized, "missing_bearer") + return + } + claims, err := verifier.Verify(req.Context(), rawToken) + if err != nil { + writeJSONError(w, http.StatusUnauthorized, "invalid_token") + return + } + caller, privileges, err := resolver.ResolveCaller(req.Context(), claims.Sub) + if errors.Is(err, ErrNotLinked) { + writeJSONError(w, http.StatusUnauthorized, "identity_not_linked") + return + } + if err != nil { + writeJSONError(w, http.StatusServiceUnavailable, "auth lookup failed") + return + } + ctx := WithCaller(req.Context(), caller) + ctx = withPrivileges(ctx, privileges) + next.ServeHTTP(w, req.WithContext(ctx)) + }) +} + +// Router is the only path that lets HTTP routes hit the mux. The mux +// field is unexported on purpose: callers must go through RequirePrivilege, RequireAuth, or Public. +type Router struct { + mux *http.ServeMux + publicPaths []string +} + +// NewRouter wraps mux. +func NewRouter(mux *http.ServeMux) *Router { return &Router{mux: mux} } + +// RequirePrivilege registers an authenticated handler. The wrapper checks privilege +// against the caller's privilege set in ctx; absent → 403. +func (r *Router) RequirePrivilege(pattern string, privilege models.PrivilegeKey, handler http.HandlerFunc) { + r.mux.HandleFunc(pattern, func(w http.ResponseWriter, req *http.Request) { + if !HasPrivilege(req.Context(), privilege) { + writeJSONError(w, http.StatusForbidden, "insufficient privilege") + return + } + handler(w, req) + }) +} + +// Public registers an unauthenticated handler. The middleware uses the path +// component to bypass JWT verification. +func (r *Router) Public(pattern string, handler http.HandlerFunc) { + r.mux.HandleFunc(pattern, handler) + r.publicPaths = append(r.publicPaths, methodPath(pattern)) +} + +// RequireAuth registers a handler that requires a verified caller but no +// specific privilege. (e.g., caller looking up their own data). +func (r *Router) RequireAuth(pattern string, handler http.HandlerFunc) { + r.mux.HandleFunc(pattern, handler) +} + +// PublicPaths returns the path components of every Public() registration, +// suitable for handing to Middleware. +func (r *Router) PublicPaths() []string { return r.publicPaths } + +func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { + r.mux.ServeHTTP(w, req) +} + +func bearer(header string) (string, bool) { + const prefix = "Bearer " + if !strings.HasPrefix(header, prefix) { + return "", false + } + return strings.TrimSpace(header[len(prefix):]), true +} + +// methodPath strips the optional "METHOD " prefix from a mux pattern, since +// the middleware bypass check compares against req.URL.Path (path only). +func methodPath(pattern string) string { + if i := strings.IndexByte(pattern, ' '); i >= 0 { + return strings.TrimSpace(pattern[i+1:]) + } + return pattern +} + +// writeJSONError emits a {"error":"..."} body to keep pkg/identity free of +// any pkg/common dependency. +func writeJSONError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} diff --git a/pkg/identity/verifier.go b/pkg/identity/verifier.go new file mode 100644 index 000000000..8044730ad --- /dev/null +++ b/pkg/identity/verifier.go @@ -0,0 +1,57 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package identity + +import ( + "context" + + "github.com/coreos/go-oidc/v3/oidc" +) + +// JWTVerifier validates bearer tokens against an OIDC provider's JWKS, +// issuer, audience, and expiry. +type JWTVerifier struct{ inner *oidc.IDTokenVerifier } + +// Claims are the minimal subset the middleware needs after verification. +type Claims struct { + Sub string `json:"sub"` + Email string `json:"email"` +} + +// NewJWTVerifier resolves the provider's discovery doc and builds a verifier +// pinned to the audience. +func NewJWTVerifier(ctx context.Context, issuer, audience string) (*JWTVerifier, error) { + provider, err := oidc.NewProvider(ctx, issuer) + if err != nil { + return nil, err + } + return &JWTVerifier{inner: provider.Verifier(&oidc.Config{ClientID: audience})}, nil +} + +// Verify checks signature, issuer, audience, and expiry. +func (j *JWTVerifier) Verify(ctx context.Context, rawToken string) (*Claims, error) { + token, err := j.inner.Verify(ctx, rawToken) + if err != nil { + return nil, err + } + var claims Claims + if err := token.Claims(&claims); err != nil { + return nil, err + } + return &claims, nil +}
