Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
reinkrul committed May 24, 2024
1 parent 1ff0438 commit c53e9c5
Show file tree
Hide file tree
Showing 11 changed files with 529 additions and 367 deletions.
16 changes: 3 additions & 13 deletions auth/api/iam/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/nuts-foundation/nuts-node/auth/api/iam/usersession"
"html/template"
"net/http"
"net/url"
Expand Down Expand Up @@ -73,17 +74,6 @@ const accessTokenValidity = 15 * time.Minute

const oid4vciSessionValidity = 15 * time.Minute

// userSessionCookieName is the name of the cookie used to store the user session.
// It uses the __Host prefix, that instructs the user agent to treat it as a secure cookie:
// - Must be set with the Secure attribute
// - Must be set from an HTTPS uri
// - Must not contain a Domain attribute
// - Must contain a Path attribute
// Also see:
// - https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/06-Session_Management_Testing/02-Testing_for_Cookies_Attributes
// - https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies
const userSessionCookieName = "__Host-SID"

//go:embed assets
var assetsFS embed.FS

Expand Down Expand Up @@ -143,11 +133,11 @@ func (r Wrapper) Routes(router core.EchoRouter) {
return next(c)
}
}, audit.Middleware(apiModuleName))
router.Use(userSessionMiddleware{
router.Use(usersession.Middleware{
Skipper: func(c echo.Context) bool {
return strings.HasSuffix(c.Path(), "/user") // user landing page
},
}.handle)
}.Handle)
}

func (r Wrapper) strictMiddleware(ctx echo.Context, request interface{}, operationID string, f StrictHandlerFunc) (interface{}, error) {
Expand Down
2 changes: 1 addition & 1 deletion auth/api/iam/openid4vp.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ func (r Wrapper) handleAuthorizeRequestFromVerifier(ctx context.Context, tenantD

// TODO: Create session if it does not exist (use client state to get original Authorization Code request)?
// Although it would be quite weird (maybe it expired).
userSession, err := r.loadUserSession(ctx.Value(httpRequestContextKey{}).(*http.Request), tenantDID, nil)
userSession, err := getUserSession(ctx.Value(httpRequestContextKey{}).(*http.Request), tenantDID, nil)
if userSession == nil {
return nil, oauth.OAuth2Error{Code: oauth.InvalidRequest, InternalError: err, Description: "no user session found"}
}
Expand Down
39 changes: 0 additions & 39 deletions auth/api/iam/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import (
"fmt"
"net/url"

"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/http"
Expand Down Expand Up @@ -137,44 +136,6 @@ func (v *PEXConsumer) credentialMap() (map[string]vc.VerifiableCredential, error
return credentialMap, nil
}

// UserSession is a session-bound Verifiable Credential wallet.
type UserSession struct {
// TenantDID is the requesting DID when the user session was created, typically the employer's (of the user) DID.
// A session needs to be scoped to the tenant DID, since the session gives access to the tenant's wallet,
// and the user session might contain session-bound credentials (e.g. EmployeeCredential) that were issued by the tenant.
TenantDID did.DID `json:"tenantDID"`
// PreAuthorizedUser is the user that is pre-authorized by the client application.
// It is stored to later assert that subsequent RequestUserAccessToken() calls that (accidentally or intentionally)
// re-use the browser session, are indeed for the same client application user.
PreAuthorizedUser *UserDetails `json:"preauthorized_user"`
Wallet UserWallet `json:"wallet"`
}

// UserWallet is a session-bound Verifiable Credential wallet.
// It's an in-memory wallet which contains the user's private key in plain text.
// This is OK, since the associated credentials are intended for protocol compatibility (OpenID4VP with a low-assurance EmployeeCredential),
// when an actual user wallet is involved, this wallet isn't used.
type UserWallet struct {
Credentials []vc.VerifiableCredential
// JWK is an in-memory key pair associated with the user's wallet in JWK form.
JWK []byte
// DID is the did:jwk DID of the user's wallet.
DID did.DID
}

// Key returns the JWK as jwk.Key
func (w UserWallet) Key() (jwk.Key, error) {
set, err := jwk.Parse(w.JWK)
if err != nil {
return nil, fmt.Errorf("failed to parse JWK: %w", err)
}
result, available := set.Key(0)
if !available {
return nil, errors.New("expected exactly 1 key in the JWK set")
}
return result, nil
}

// ServerState is a convenience type for extracting different types of data from the session.
type ServerState struct {
CredentialMap map[string]vc.VerifiableCredential
Expand Down
9 changes: 0 additions & 9 deletions auth/api/iam/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@
package iam

import (
"net/http"

"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/auth/oauth"
Expand Down Expand Up @@ -66,13 +64,6 @@ type WalletOwnerType = pe.WalletOwnerType
// RequiredPresentationDefinitions is an alias
type RequiredPresentationDefinitions = pe.WalletOwnerMapping

// CookieReader is an interface for reading cookies from an HTTP request.
// It is implemented by echo.Context and http.Request.
type CookieReader interface {
// Cookie returns the named cookie provided in the request.
Cookie(name string) (*http.Cookie, error)
}

const (
// responseModeQuery returns the answer to the authorization request append as query parameters to the provided redirect_uri
responseModeQuery = "query" // default if no response_mode is specified
Expand Down
198 changes: 20 additions & 178 deletions auth/api/iam/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,13 @@ package iam

import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
middleware2 "github.com/labstack/echo/v4/middleware"
"github.com/nuts-foundation/nuts-node/auth/api/iam/usersession"
"net/http"
"strings"
"time"

"github.com/labstack/echo/v4"
"github.com/lestrrat-go/jwx/v2/jwk"
ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
Expand All @@ -49,61 +45,11 @@ const (
// userRedirectTimeout is the timeout for the user redirect session.
// This is the maximum time between the creation of the redirect for the user and the actual GET request to the user/wallet page.
userRedirectTimeout = time.Second * 5
// userSessionTimeout is the timeout for the user session.
// This is the TTL of the server side state and the cookie.
userSessionTimeout = time.Hour
)

var oauthClientStateKey = []string{"oauth", "client_state"}
var oauthCodeKey = []string{"oauth", "code"}
var userRedirectSessionKey = []string{"user", "redirect"}
var userSessionKey = []string{"user", "session"}

const userSessionEchoContextKey = "userSession"

type userSessionMiddleware struct {
Skipper middleware2.Skipper
sessionDatabase storage.SessionDatabase
}

func (u userSessionMiddleware) handle(next echo.HandlerFunc) echo.HandlerFunc {
return func(echoCtx echo.Context) error {
if u.Skipper(echoCtx) {
return next(echoCtx)
}
tenantDIDRaw := echoCtx.Param("did")
if tenantDIDRaw == "" {
// Indicates misconfiguration
return errors.New("missing tenant DID")
}
tenantDID, err := did.ParseDID(tenantDIDRaw)
if err != nil {
return fmt.Errorf("invalid tenant DID: %w", err)
}

session, err := u.loadUserSession(echoCtx, *tenantDID, nil)
if err != nil {
// Should only really occur in exceptional circumstances (e.g. cookie survived after intended max age).
log.Logger().WithError(err).Info("Invalid user session, a new session will be created")
}
if session == nil {
wallet, err := r.createUserWallet(echoCtx.Request().Context(), redirectSession.OwnDID, *accessTokenRequest.Body.PreauthorizedUser)
if err != nil {
return fmt.Errorf("create user wallet: %w", err)
}
// this causes the session cookie to be set
if err = r.createUserSession(echoCtx, UserSession{
TenantDID: redirectSession.OwnDID,
Wallet: *wallet,
PreAuthorizedUser: accessTokenRequest.Body.PreauthorizedUser,
}); err != nil {
return fmt.Errorf("create user session: %w", err)
}
}

return next(echoCtx)
}
}

// handleUserLanding is the handler for the landing page of the user.
// It renders the page with the correct context based on the token.
Expand Down Expand Up @@ -136,10 +82,14 @@ func (r Wrapper) handleUserLanding(echoCtx echo.Context) error {
return err
}

// Assure there's a user session
if _, err := getSession(echoCtx, accessTokenRequest.Did); err != nil {
// Make sure there's a user session, loaded with EmployeeCredential
userSession, err := usersession.Get(echoCtx.Request().Context(), accessTokenRequest.Did)
if err != nil {
return err
}
if err := r.provisionUserSession(echoCtx.Request().Context(), *userSession, *redirectSession.AccessTokenRequest.Body.PreauthorizedUser); err != nil {
return fmt.Errorf("couldn't provision user session: %w", err)
}

// burn token
err = store.Delete(token)
Expand Down Expand Up @@ -195,10 +145,6 @@ func (r Wrapper) userRedirectStore() storage.SessionStore {
return r.storageEngine.GetSessionDatabase().GetStore(userRedirectTimeout, userRedirectSessionKey...)
}

func (u userSessionMiddleware) userSessionStore() storage.SessionStore {
return u.sessionDatabase.GetStore(userSessionTimeout, userSessionKey...)
}

func (r Wrapper) oauthClientStateStore() storage.SessionStore {
return r.storageEngine.GetSessionDatabase().GetStore(oAuthFlowTimeout, oauthClientStateKey...)
}
Expand All @@ -207,108 +153,31 @@ func (r Wrapper) oauthCodeStore() storage.SessionStore {
return r.storageEngine.GetSessionDatabase().GetStore(oAuthFlowTimeout, oauthCodeKey...)
}

// loadUserSession loads the user session given the session ID in the cookie.
// If there is no session cookie (not yet authenticated, or the session expired), nil is returned.
// If another, technical error occurs when retrieving the session.
func (u userSessionMiddleware) loadUserSession(cookies CookieReader, tenantDID did.DID, preAuthorizedUser *UserDetails) (*UserSession, error) {
cookie, err := cookies.Cookie(userSessionCookieName)
if err != nil {
// sadly, no cookie for you
// Cookie only returns http.ErrNoCookie
return nil, nil
}
session := new(UserSession)
if err = u.userSessionStore().Get(cookie.Value, session); errors.Is(err, storage.ErrNotFound) {
return nil, errors.New("unknown or expired session")
} else if err != nil {
// other error occurred
return nil, fmt.Errorf("invalid user session: %w", err)
func (r Wrapper) provisionUserSession(ctx context.Context, session usersession.Data, preAuthorizedUser UserDetails) error {
if len(session.Wallet.Credentials) > 0 {
// already provisioned
return nil
}
// Note that the session itself does not have an expiration field:
// it depends on the session store to clean up when it expires.
if !session.TenantDID.Equals(tenantDID) && !session.Wallet.DID.Equals(tenantDID) {
return nil, fmt.Errorf("session belongs to another tenant (%s)", session.TenantDID)
}
// If the existing session was created for a pre-authorized user, the call to RequestUserAccessToken() must be
// for the same user.
// TODO: When we support external Identity Providers, make sure the existing session was not for a preauthorized user.
if preAuthorizedUser != nil && *preAuthorizedUser != *session.PreAuthorizedUser {
return nil, errors.New("session belongs to another pre-authorized user")
}
return session, nil
}

func (r Wrapper) createUserSession(ctx echo.Context, session UserSession) error {
sessionID := crypto.GenerateNonce()
if err := r.userSessionStore().Put(sessionID, session); err != nil {
employeeCredential, err := r.issueEmployeeCredential(ctx, session, preAuthorizedUser)
if err != nil {
return err
}
// Do not set Expires: then it isn't a session cookie anymore.
// TODO: we could make this more secure by narrowing the Path, but we currently have the following user-facing paths:
// - /iam/:did/(openid4vp_authz_accept)
// - /oauth2/:did/user
// If we move these under a common base path (/oauth2 or /iam), we could use that as Path property
// The issue with the current approach is that we have a single cookie for the whole domain,
// thus a new user session for a different DID will overwrite the current one (since a new cookie is created).
// By scoping the cookies to a tenant (DID)-specific path, they can co-exist.
var path string
if r.auth.PublicURL().Path != "" {
path = r.auth.PublicURL().Path
} else {
path = "/"
}
ctx.SetCookie(createUserSessionCookie(sessionID, path))
return nil
}

func getSession(ctx echo.Context, tenantDID string) (*UserSession, error) {
result, ok := ctx.Get(userSessionCookieName).(*UserSession)
if !ok {
return nil, errors.New("no user session found")
}
if result.TenantDID.String() != tenantDID {
return nil, errors.New("user session belongs to another tenant")
}
return result, nil
}

func createUserSessionCookie(sessionID string, path string) *http.Cookie {
return &http.Cookie{
Name: userSessionCookieName,
Value: sessionID,
Path: path,
MaxAge: int(userSessionTimeout.Seconds()),
Secure: true,
HttpOnly: true, // do not let JavaScript
SameSite: http.SameSiteStrictMode, // do not allow the cookie to be sent with cross-site requests
}
session.Wallet.Credentials = append(session.Wallet.Credentials, *employeeCredential)
return session.Save()
}

func (r Wrapper) createUserWallet(ctx context.Context, issuerDID did.DID, userDetails UserDetails) (*UserWallet, error) {
userJWK, userDID, err := generateUserSessionJWK()
if err != nil {
return nil, err
}
userJWKBytes, err := json.Marshal(userJWK)
if err != nil {
return nil, err
}
// create user session wallet
wallet := UserWallet{
JWK: userJWKBytes,
DID: *userDID,
}
func (r Wrapper) issueEmployeeCredential(ctx context.Context, data usersession.Data, userDetails UserDetails) (*vc.VerifiableCredential, error) {
issuanceDate := time.Now()
expirationDate := issuanceDate.Add(userSessionTimeout)
expirationDate := data.ExpiresAt
template := vc.VerifiableCredential{
Context: []ssi.URI{credential.NutsV1ContextURI},
Type: []ssi.URI{ssi.MustParseURI("EmployeeCredential")},
Issuer: issuerDID.URI(),
Issuer: data.TenantDID.URI(),
IssuanceDate: issuanceDate,
ExpirationDate: &expirationDate,
CredentialSubject: []interface{}{
map[string]string{
"id": userDID.String(),
"id": data.Wallet.DID.String(),
"identifier": userDetails.Id,
"name": userDetails.Name,
"roleName": userDetails.Role,
Expand All @@ -324,32 +193,5 @@ func (r Wrapper) createUserWallet(ctx context.Context, issuerDID did.DID, userDe
if err != nil {
return nil, fmt.Errorf("issue EmployeeCredential: %w", err)
}
wallet.Credentials = append(wallet.Credentials, *employeeCredential)
return &wallet, nil
}

func generateUserSessionJWK() (jwk.Key, *did.DID, error) {
// Generate a key pair and JWK for storage
userJWK, err := crypto.GenerateJWK()
if err != nil {
return nil, nil, err
}
// Now derive the did:jwk DID
publicKey, err := userJWK.PublicKey()
if err != nil {
return nil, nil, err
}
publicUserJSON, err := json.Marshal(publicKey)
if err != nil {
return nil, nil, err
}
userDID, err := did.ParseDID("did:jwk:" + base64.RawStdEncoding.EncodeToString(publicUserJSON))
if err != nil {
return nil, nil, err
}
if err := userJWK.Set(jwk.KeyIDKey, userDID.String()+"#0"); err != nil {
return nil, nil, err
}

return userJWK, userDID, nil
return employeeCredential, nil
}
Loading

0 comments on commit c53e9c5

Please sign in to comment.