Skip to content

Commit

Permalink
OIDC-based user authentication
Browse files Browse the repository at this point in the history
Merge branch 'mrtamm/develop' into develop
  • Loading branch information
mrtamm authored and lbeckman314 committed Jan 18, 2025
1 parent 0d676fe commit d47c09c
Show file tree
Hide file tree
Showing 13 changed files with 1,106 additions and 267 deletions.
1 change: 1 addition & 0 deletions cmd/server/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ func NewServer(ctx context.Context, conf config.Config, log *logger.Logger) (*Se
RPCAddress: ":" + conf.Server.RPCPort,
HTTPPort: conf.Server.HTTPPort,
BasicAuth: conf.Server.BasicAuth,
OidcAuth: conf.Server.OidcAuth,
DisableHTTPCache: conf.Server.DisableHTTPCache,
Log: log,
Tasks: &server.TaskService{
Expand Down
10 changes: 10 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ type BasicCredential struct {
Password string
}

type OidcAuth struct {
ServiceConfigURL string
ClientId string
ClientSecret string
RedirectURL string
RequireScope string
RequireAudience string
}

// RPCClient describes configuration for gRPC clients
type RPCClient struct {
BasicCredential
Expand All @@ -77,6 +86,7 @@ type Server struct {
HTTPPort string
RPCPort string
BasicAuth []BasicCredential
OidcAuth OidcAuth
DisableHTTPCache bool
}

Expand Down
17 changes: 17 additions & 0 deletions config/default-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,23 @@ Server:
# - User: user2
# Password: foobar

# Require Bearer JWT authentication for the server APIs.
# Server won't launch when configuration URL cannot be loaded.
# OidcAuth:
# # URL of the OIDC service configuration (activates OIDC configuration):
# # Example: https://example.org/oidc/.well-knwon/openid-configuration
# ServiceConfigURL:
# # Client ID and secret are sent with the token introspection request
# # (Basic authentication):
# ClientId:
# ClientSecret:
# # The URL where OIDC should redirect after login (keep the path '/login')
# RedirectURL: "http://localhost:8000/login"
# # Optional: if specified, this scope value must be in the token:
# RequireScope:
# # Optional: if specified, this audience value must be in the token:
# RequireAudience:

# Include a "Cache-Control: no-store" HTTP header in Get/List responses
# to prevent caching by intermediary services.
DisableHTTPCache: true
Expand Down
10 changes: 10 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ require (
github.com/containerd/log v0.1.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
github.com/dgraph-io/ristretto v0.2.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
Expand All @@ -97,11 +98,13 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/gammazero/deque v0.2.1 // indirect
github.com/go-bindata/go-bindata v3.1.2+incompatible // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 // indirect
Expand All @@ -125,6 +128,12 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.10 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/jwx/v2 v2.1.3 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/maruel/panicparse v1.6.2 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
Expand All @@ -149,6 +158,7 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect
Expand Down
21 changes: 21 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/dgraph-io/badger/v2 v2.2007.4 h1:TRWBQg8UrlUhaFdco01nO2uXwzKS7zd+HVdwV/GHc4o=
github.com/dgraph-io/badger/v2 v2.2007.4/go.mod h1:vSw/ax2qojzbN6eXHIx6KPKtCSHJN/Uz0X0VPruTIhk=
github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E=
Expand Down Expand Up @@ -125,6 +127,8 @@ github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gizak/termui v2.3.0+incompatible h1:S8wJoNumYfc/rR5UezUM4HsPEo3RJh0LKdiuDWQpjqw=
github.com/gizak/termui v2.3.0+incompatible/go.mod h1:PkJoWUt/zacQKysNfQtcw1RW+eK2SxkieVBtl+4ovLA=
github.com/go-bindata/go-bindata v3.1.2+incompatible h1:5vjJMVhowQdPzjE1LdxyFF7YFTXg5IgGVW4gBr5IbvE=
github.com/go-bindata/go-bindata v3.1.2+incompatible/go.mod h1:xK8Dsgwmeed+BBsSy2XTopBn/8uK2HWuGSnA11C3Joo=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
Expand All @@ -150,6 +154,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/gddo v0.0.0-20210115222349-20d68f94ee1f h1:16RtHeWGkJMc80Etb8RPCcKevXGldr57+LOyZt8zOlg=
Expand Down Expand Up @@ -273,6 +279,18 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx/v2 v2.1.3 h1:Ud4lb2QuxRClYAmRleF50KrbKIoM1TddXgBrneT5/Jo=
github.com/lestrrat-go/jwx/v2 v2.1.3/go.mod h1:q6uFgbgZfEmQrfJfrCo90QcQOcXFMfbI/fO0NqRtvZo=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
github.com/magiconair/properties v1.7.4-0.20170902060319-8d7837e64d3c/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
Expand Down Expand Up @@ -369,6 +387,8 @@ github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
Expand Down Expand Up @@ -406,6 +426,7 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
Expand Down
183 changes: 127 additions & 56 deletions server/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package server

import (
"encoding/base64"
"net/http"
"strings"

"github.com/ohsu-comp-bio/funnel/config"
Expand All @@ -12,75 +13,145 @@ import (
"google.golang.org/grpc/status"
)

// Return a new interceptor function that authorizes RPCs
// using a password stored in the config.
func newAuthInterceptor(creds []config.BasicCredential) grpc.UnaryServerInterceptor {

// Return a function that is the interceptor.
return func(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (interface{}, error) {
var authorized bool
var err error
for _, cred := range creds {
err = authorize(ctx, cred.User, cred.Password)
if err == nil {
authorized = true
}
}
if len(creds) == 0 {
authorized = true
}
if !authorized {
return nil, err
}
return handler(ctx, req)
}
type Authentication struct {
basic map[string]string
oidc *OidcConfig
}

// Check the context's metadata for the configured server/API password.
func authorize(ctx context.Context, user, password string) error {
if md, ok := metadata.FromIncomingContext(ctx); ok {
if len(md["authorization"]) > 0 {
raw := md["authorization"][0]
requser, reqpass, ok := parseBasicAuth(raw)
if ok {
if requser == user && reqpass == password {
return nil
}
return status.Errorf(codes.PermissionDenied, "AUTH DENIED")
}
}
// Extracted info about the current user, which is exposed through Context.
type UserInfo struct {
// Public users are non-authenticated, in case Funnel configuration does
// not require OIDC nor Basic authentication.
IsPublic bool
// Username of an authenticated user (subject field from JWT).
Username string
// In case of OIDC authentication, the provided Bearer token, which can be
// used when requesting task input data.
Token string
}

// Context key type for storing UserInfo.
// Note: UserInfo is not in the context when the system internally requests data.
type userInfoContextKey string

var (
errMissingMetadata = status.Errorf(codes.InvalidArgument, "Missing metadata in the context")
errTokenRequired = status.Errorf(codes.Unauthenticated, "Basic/Bearer authorization token missing")
errInvalidBasicToken = status.Errorf(codes.Unauthenticated, "Basic-authentication failed")
errInvalidBearerToken = status.Errorf(codes.Unauthenticated, "Bearer authorization token not accepted")
publicUserInfo = UserInfo{IsPublic: true, Username: ""}
UserInfoKey = userInfoContextKey("user-info")
)

func NewAuthentication(creds []config.BasicCredential, oidc config.OidcAuth) *Authentication {
basicCreds := make(map[string]string)

for _, cred := range creds {
credBytes := []byte(cred.User + ":" + cred.Password)
fullValue := "Basic " + base64.StdEncoding.EncodeToString(credBytes)
basicCreds[fullValue] = cred.User
}

return status.Errorf(codes.Unauthenticated, "UNAUTHENTICATED")
return &Authentication{
basic: basicCreds,
oidc: initOidcConfig(oidc),
}
}

// parseBasicAuth parses an HTTP Basic Authentication string.
// "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==" returns ("Aladdin", "open sesame", true).
//
// Taken from Go core: https://golang.org/src/net/http/request.go?s=27379:27445#L828
func parseBasicAuth(auth string) (username, password string, ok bool) {
const prefix = "Basic "
// Return a new gRPC interceptor function that authorizes RPCs.
func (a *Authentication) Interceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler) (interface{}, error) {

// Case when authentication is not required:
if len(a.basic) == 0 && a.oidc == nil {
ctx = context.WithValue(ctx, UserInfoKey, &publicUserInfo)
return handler(ctx, req)
}

md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, errMissingMetadata
}

if !strings.HasPrefix(auth, prefix) {
return
values := md["authorization"]
if len(values) == 0 {
return nil, errTokenRequired
}

c, err := base64.StdEncoding.DecodeString(auth[len(prefix):])
authorized := false
authErr := errTokenRequired
authorization := values[0]

if strings.HasPrefix(authorization, "Basic ") {
authErr = errInvalidBasicToken
username := a.basic[authorization]
authorized = username != ""

if err != nil {
return
if authorized {
ctx = context.WithValue(ctx, UserInfoKey, &UserInfo{Username: username})
}
} else if a.oidc != nil && strings.HasPrefix(authorization, "Bearer ") {
authErr = errInvalidBearerToken
jwtString := strings.TrimPrefix(authorization, "Bearer ")
subject := a.oidc.ParseJwtSubject(jwtString)
authorized = subject != ""

if authorized {
ctx = context.WithValue(ctx, UserInfoKey, &UserInfo{Username: subject, Token: jwtString})
}
}

cs := string(c)
s := strings.IndexByte(cs, ':')
if !authorized {
return nil, authErr
}

return handler(ctx, req)
}

if s < 0 {
return
// HTTP request handler for the /login endpoint. Initiates user authentication
// flow based on the configuration (OIDC, Basic, none).
func (a *Authentication) LoginHandler(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Only GET method is supported.", http.StatusMethodNotAllowed)
}

return cs[:s], cs[s+1:], true
if a.oidc != nil {
a.oidc.HandleAuthCode(w, req)
} else if len(a.basic) > 0 {
a.handleBasicAuth(w, req)
} else {
http.Redirect(w, req, "/", http.StatusSeeOther)
}
}

// HTTP request handler for the /login/token endpoint. In case of OIDC enabled,
// prints the JWT from the sent cookie. In all other cases, an empty HTTP 200
// response.
func (a *Authentication) EchoTokenHandler(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Only GET method is supported.", http.StatusMethodNotAllowed)
}

if a.oidc != nil {
a.oidc.EchoTokenHandler(w, req)
} else {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Length", "0")
w.WriteHeader(http.StatusOK)
}
}

func (a *Authentication) handleBasicAuth(w http.ResponseWriter, req *http.Request) {
// Check if provided value in the header is valid:
if a.basic[req.Header.Get("Authorization")] == "" {
http.Redirect(w, req, "/", http.StatusSeeOther)
} else {
w.Header().Set("WWW-Authenticate", "Basic realm=Funnel")
msg := "User authentication is required (Basic authentication with " +
"username and password)"
http.Error(w, msg, http.StatusUnauthorized)
}
}
Loading

0 comments on commit d47c09c

Please sign in to comment.