Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(was: Prototype to provide user-defined upsteam-to-downstream identity transformations/filters via Starlark) Replaced by #694 #693

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ require (
github.com/spf13/cobra v1.1.3
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.7.0
go.starlark.net v0.0.0-20210602144842-1cdb82c9e17a
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,7 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
Expand Down Expand Up @@ -1162,6 +1163,8 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.13.0/go.mod h1:TwTkyRaTam1pOIb2wxcAiC2hkMVbokXkt6DEt5nDkD8=
go.opentelemetry.io/otel v0.13.0/go.mod h1:dlSNewoRYikTkotEnxdmuBHgzT+k/idJSfDv/FxEnOY=
go.starlark.net v0.0.0-20210602144842-1cdb82c9e17a h1:wDtSCWGrX9tusypq2Qq9xzaA3Tf/+4D2KaWO+HQvGZE=
go.starlark.net v0.0.0-20210602144842-1cdb82c9e17a/go.mod h1:t3mmBBPzAVvK0L0n1drDmrQsJ8FoIx4INCqVMTr/Zo0=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.1 h1:rsqfU5vBkVknbhUGbAUwQKR2H4ItV8tjJ+6kJX4cxHM=
Expand Down
31 changes: 11 additions & 20 deletions internal/oidc/auth/auth_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"go.pinniped.dev/internal/httputil/securityheader"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/csrftoken"
"go.pinniped.dev/internal/oidc/downstreamsession"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/pkg/oidcclient/nonce"
Expand Down Expand Up @@ -109,18 +110,11 @@ func handleAuthRequestForLDAPUpstream(
return nil
}

now := time.Now().UTC()
openIDSession := &openid.DefaultSession{
Claims: &jwt.IDTokenClaims{
Subject: downstreamSubjectFromUpstreamLDAP(ldapUpstream, authenticateResponse),
RequestedAt: now,
AuthTime: now,
},
}
openIDSession.Claims.Extra = map[string]interface{}{
oidc.DownstreamUsernameClaim: authenticateResponse.User.GetName(),
oidc.DownstreamGroupsClaim: authenticateResponse.User.GetGroups(),
}
openIDSession := downstreamsession.MakeDownstreamSession(
downstreamSubjectFromUpstreamLDAP(ldapUpstream, authenticateResponse),
authenticateResponse.User.GetName(),
authenticateResponse.User.GetGroups(),
)

authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession)
if err != nil {
Expand All @@ -130,6 +124,7 @@ func handleAuthRequestForLDAPUpstream(
}

oauthHelper.WriteAuthorizeResponse(w, authorizeRequester, authorizeResponder)

return nil
}

Expand Down Expand Up @@ -236,18 +231,14 @@ func newAuthorizeRequest(r *http.Request, w http.ResponseWriter, oauthHelper fos
oauthHelper.WriteAuthorizeError(w, authorizeRequester, err)
return nil, false
}
grantScopes(authorizeRequester)
return authorizeRequester, true
}

func grantScopes(authorizeRequester fosite.AuthorizeRequester) {
// Automatically grant the openid, offline_access, and pinniped:request-audience scopes, but only if they were requested.
// Grant the openid scope (for now) if they asked for it so that `NewAuthorizeResponse` will perform its OIDC validations.
oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOpenID)
// There don't seem to be any validations inside `NewAuthorizeResponse` related to the offline_access scope
// at this time, however we will temporarily grant the scope just in case that changes in a future release of fosite.
oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOfflineAccess)
// Grant the pinniped:request-audience scope if requested.
oidc.GrantScopeIfRequested(authorizeRequester, "pinniped:request-audience")
downstreamsession.GrantScopesIfRequested(authorizeRequester)

return authorizeRequester, true
}

func readCSRFCookie(r *http.Request, codec oidc.Decoder) csrftoken.CSRFToken {
Expand Down
31 changes: 4 additions & 27 deletions internal/oidc/callback/callback_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,14 @@ import (
"fmt"
"net/http"
"net/url"
"time"

coreosoidc "github.com/coreos/go-oidc/v3/oidc"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/openid"
"github.com/ory/fosite/token/jwt"

"go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/httputil/securityheader"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/csrftoken"
"go.pinniped.dev/internal/oidc/downstreamsession"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/plog"
)
Expand Down Expand Up @@ -65,9 +62,7 @@ func NewHandler(
}

// Automatically grant the openid, offline_access, and pinniped:request-audience scopes, but only if they were requested.
oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOpenID)
oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOfflineAccess)
oidc.GrantScopeIfRequested(authorizeRequester, "pinniped:request-audience")
downstreamsession.GrantScopesIfRequested(authorizeRequester)

token, err := upstreamIDPConfig.ExchangeAuthcodeAndValidateTokens(
r.Context(),
Expand All @@ -91,7 +86,8 @@ func NewHandler(
return err
}

openIDSession := makeDownstreamSession(subject, username, groups)
openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups)

authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession)
if err != nil {
plog.WarningErr("error while generating and saving authcode", err, "upstreamName", upstreamIDPConfig.GetName())
Expand Down Expand Up @@ -347,22 +343,3 @@ func extractGroups(groupsAsInterface interface{}) ([]string, bool) {

return groupsAsStrings, true
}

func makeDownstreamSession(subject string, username string, groups []string) *openid.DefaultSession {
now := time.Now().UTC()
openIDSession := &openid.DefaultSession{
Claims: &jwt.IDTokenClaims{
Subject: subject,
RequestedAt: now,
AuthTime: now,
},
}
if groups == nil {
groups = []string{}
}
openIDSession.Claims.Extra = map[string]interface{}{
oidc.DownstreamUsernameClaim: username,
oidc.DownstreamGroupsClaim: groups,
}
return openIDSession
}
43 changes: 43 additions & 0 deletions internal/oidc/downstreamsession/downstream_session.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

// Package downstreamsession provides some shared helpers for creating downstream OIDC sessions.
package downstreamsession

import (
"time"

oidc2 "github.com/coreos/go-oidc/v3/oidc"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/openid"
"github.com/ory/fosite/token/jwt"

"go.pinniped.dev/internal/oidc"
)

// MakeDownstreamSession creates a downstream OIDC session.
func MakeDownstreamSession(subject string, username string, groups []string) *openid.DefaultSession {
now := time.Now().UTC()
openIDSession := &openid.DefaultSession{
Claims: &jwt.IDTokenClaims{
Subject: subject,
RequestedAt: now,
AuthTime: now,
},
}
if groups == nil {
groups = []string{}
}
openIDSession.Claims.Extra = map[string]interface{}{
oidc.DownstreamUsernameClaim: username,
oidc.DownstreamGroupsClaim: groups,
}
return openIDSession
}

// GrantScopesIfRequested auto-grants the scopes for which we do not require end-user approval, if they were requested.
func GrantScopesIfRequested(authorizeRequester fosite.AuthorizeRequester) {
oidc.GrantScopeIfRequested(authorizeRequester, oidc2.ScopeOpenID)
oidc.GrantScopeIfRequested(authorizeRequester, oidc2.ScopeOfflineAccess)
oidc.GrantScopeIfRequested(authorizeRequester, "pinniped:request-audience")
}
183 changes: 183 additions & 0 deletions internal/starformer/starformer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

// Package starformer is an implementation of UpstreamToDownstreamTransformer using Starlark scripts.
// See Starlark dialect language documentation here: https://github.com/google/starlark-go/blob/master/doc/spec.md
// A video introduction to Starlark and how to integrate it into projects is here: https://www.youtube.com/watch?v=9P_YKVhncWI
package starformer

import (
"fmt"

"go.starlark.net/lib/json"
starlarkmath "go.starlark.net/lib/math"
"go.starlark.net/lib/time"
"go.starlark.net/resolve"
"go.starlark.net/starlark"

"go.pinniped.dev/internal/plog"
)

const (
maxExecutionSteps = 10000000
transformFunctionName = "transform"
)

// Configure some global variables in starlark-go.
// nolint:gochecknoinits // wish these weren't globals but oh well
func init() {
// Allow the non-standard "set" data structure to be used.
resolve.AllowSet = true

// Note that we could allow "while" statements and recursive functions, but the language already
// has "for" loops so it seems unnecessary for our use case. This is currently the default
// value in starlark-go but repeating it here as documentation.
resolve.AllowRecursion = false
}

type Transformer struct {
hook *starlark.Function
}

// New creates an instance of Transformer. Given some Starlark source code as a string, it loads the code.
// If there is any error during loading, it will return the error. It expects the loaded code to define
// a Starlark function called "transform" which should take two positional arguments. The returned
// Transformer can be safely called from multiple threads simultaneously, no matter how the Starlark
// source code was written, because the Starlark module has been frozen (made immutable).
func New(starlarkSourceCode string) (*Transformer, error) {
// Create a Starlark thread in which the source will be loaded.
thread := &starlark.Thread{
Name: "starlark script loader",
Print: func(thread *starlark.Thread, msg string) {
// When the script has a top-level print(), send it to the server log.
plog.Debug("debug message while loading starlark transform script", "msg", msg)
},
Load: func(thread *starlark.Thread, module string) (starlark.StringDict, error) {
// Allow starlark-go's custom built-in modules to be loaded by scripts if they desire.
switch module {
case "json.star":
return starlark.StringDict{"json": json.Module}, nil
case "time.star":
return starlark.StringDict{"time": time.Module}, nil
case "math.star":
return starlark.StringDict{"math": starlarkmath.Module}, nil
default:
// Don't allow any other file to be loaded.
return nil, fmt.Errorf("only the following modules may be loaded: json.star, time.star, math.star")
}
},
}

// Prevent the top-level statements of the Starlark script from accidentally running forever.
thread.SetMaxExecutionSteps(maxExecutionSteps)

// Start with empty predeclared names, aside from the built-ins.
predeclared := starlark.StringDict{}

// Load a Starlark script. Initialization of a script runs its top-level statements from top to bottom,
// and then "freezes" all of the values making them immutable. The result can be used in multiple threads
// simultaneously without interfering, communicating, or racing with each other. The filename given here
// will appear in some Starlark error messages.
globals, err := starlark.ExecFile(thread, "transform.star", starlarkSourceCode, predeclared)
if err != nil {
return nil, fmt.Errorf("error while loading starlark transform script: %w", err)
}

// Get the function called "transform" from the global state of the module that was just loaded.
hook, _ := globals[transformFunctionName].(*starlark.Function)
if hook == nil {
return nil, fmt.Errorf("starlark script does not define %q function", transformFunctionName)
}

// Check that the "transform" function takes the expected number of arguments so we can call it later.
if hook.NumParams() != 2 {
return nil, fmt.Errorf("starlark script's global %q function has %d parameters but should have 2", transformFunctionName, hook.NumParams())
}

return &Transformer{hook: hook}, nil
}

// Transform calls the Starlark "transform" function that was loaded by New. The username and groups params are
// passed into the Starlark function, and the return values of the Starlark function are returned. If there is an error
// during the call to the Starlark function (either a programming error, a runtime error, or an intentional call to
// Starlark's `fail` built-in function) then Transform will return the error. This function is thread-safe.
// The runtime of this function depends on the complexity of the Starlark source code, but for a typical Starlark
// function will be something on the order of 50µs on a modern laptop.
func (t *Transformer) Transform(username string, groups []string) (string, []string, error) {
// TODO: maybe add a context param for cancellation, which is supported in starlark-go by
// calling thread.Cancel() from any goroutine, or maybe this doesn't matter because there is
// already a maxExecutionSteps so scripts are guaranteed to finish within a reasonable time.

// Create a Starlark thread in which the function will be called.
thread := &starlark.Thread{
Name: "starlark script executor",
Print: func(thread *starlark.Thread, msg string) {
// When the script's 'transform' function has a print(), send it to the server log.
plog.Debug("debug message while running starlark transform script", "msg", msg)
},
}

// Prevent the Starlark function from accidentally running forever.
thread.SetMaxExecutionSteps(maxExecutionSteps)

// Prepare the function arguments as Starlark values.
groupsTuple := starlark.Tuple{}
for _, group := range groups {
groupsTuple = append(groupsTuple, starlark.String(group))
}
args := starlark.Tuple{starlark.String(username), groupsTuple}

// Call the Starlark hook function in the new thread and pass the arguments.
// Get back the function's return value or an error.
hookReturnValue, err := starlark.Call(thread, t.hook, args, nil)

// Errors could be programming mistakes in the script, or could be an intentional usage of the `fail` built-in.
// Either way, return an error to reject the login.
if err != nil {
return "", nil, fmt.Errorf("error while running starlark %q function: %w", transformFunctionName, err)
}

// The special Starlark value 'None' is interpreted here as a shortcut to mean make no edits.
if hookReturnValue == starlark.None {
return username, groups, nil
}

// TODO: maybe offer a way for the user to reject a login with a nice error message which we can distinguish from
// an accidental coding error, for example by returning a single string from their 'transform' function instead
// of a tuple, or by returning a special value that we set up in the module's state in advance like
// `return rejectAuthentication(message)`

// Otherwise the function should have returned a tuple with two values.
returnedTuple, ok := hookReturnValue.(starlark.Tuple)
if !ok || returnedTuple.Len() != 2 {
return "", nil, fmt.Errorf("expected starlark %q function to return None or a Tuple of length 2", transformFunctionName)
}

// The first value in the returned tuple is the username. Turn it back into a golang string.
transformedUsername, ok := starlark.AsString(returnedTuple.Index(0))
if !ok || len(transformedUsername) == 0 {
return "", nil, fmt.Errorf("expected starlark %q function's return tuple to have a non-empty string as the first value", transformFunctionName)
}

// The second value in the returned tuple is an iterable of group names.
returnedGroups, ok := returnedTuple.Index(1).(starlark.Iterable)
if !ok {
return "", nil, fmt.Errorf("expected starlark %q function's return tuple to have an iterable value as the second value", transformFunctionName)
}

// Turn the returned iterable of group names back into a golang []string, including turning an empty iterable into an empty slice.
transformedGroupNames := []string{}
groupsIterator := returnedGroups.Iterate()
defer groupsIterator.Done()
var transformedGroup starlark.Value
for groupsIterator.Next(&transformedGroup) {
transformedGroupName, ok := starlark.AsString(transformedGroup)
if !ok || len(transformedGroupName) == 0 {
return "", nil, fmt.Errorf("expected starlark %q function's return tuple's second value to contain only non-empty strings", transformFunctionName)
}
transformedGroupNames = append(transformedGroupNames, transformedGroupName)
}

// Got username and group names, so return them as the transformed values.
return transformedUsername, transformedGroupNames, nil
}
Loading