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
+}

Reply via email to