From c71e7fa12f38a49976bf842ff1283603f82e04af Mon Sep 17 00:00:00 2001 From: Melora Hugues Date: Tue, 15 Oct 2024 19:35:14 +0200 Subject: [PATCH] Add login workflow until token generation (#48) --- polyculeconnect/cmd/serve/serve.go | 23 +++- polyculeconnect/go.mod | 16 +-- polyculeconnect/go.sum | 36 +++---- .../internal/db/authcode/authcode.go | 62 +++++++++++ .../internal/db/authrequest/authrequest.go | 35 +++++- polyculeconnect/internal/db/base.go | 6 ++ polyculeconnect/internal/model/authcode.go | 9 ++ polyculeconnect/internal/model/authrequest.go | 2 +- polyculeconnect/internal/model/client.go | 6 +- polyculeconnect/internal/model/signingkey.go | 57 ++++++++++ polyculeconnect/internal/model/token.go | 21 ++++ polyculeconnect/internal/storage/storage.go | 102 ++++++++++++++++-- .../migrations/1_create_auth_request.up.sql | 2 +- .../migrations/4_add_code_challenge.down.sql | 2 + .../migrations/4_add_code_challenge.up.sql | 2 + .../5_add_auth_request_auth_time.down.sql | 1 + .../5_add_auth_request_auth_time.up.sql | 1 + polyculeconnect/polyculeconnect.db | Bin 135168 -> 151552 bytes 18 files changed, 334 insertions(+), 49 deletions(-) create mode 100644 polyculeconnect/internal/db/authcode/authcode.go create mode 100644 polyculeconnect/internal/model/authcode.go create mode 100644 polyculeconnect/internal/model/signingkey.go create mode 100644 polyculeconnect/internal/model/token.go create mode 100644 polyculeconnect/migrations/4_add_code_challenge.down.sql create mode 100644 polyculeconnect/migrations/4_add_code_challenge.up.sql create mode 100644 polyculeconnect/migrations/5_add_auth_request_auth_time.down.sql create mode 100644 polyculeconnect/migrations/5_add_auth_request_auth_time.up.sql diff --git a/polyculeconnect/cmd/serve/serve.go b/polyculeconnect/cmd/serve/serve.go index 83a879b..93a68e3 100644 --- a/polyculeconnect/cmd/serve/serve.go +++ b/polyculeconnect/cmd/serve/serve.go @@ -2,6 +2,9 @@ package serve import ( "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" "log/slog" "os" "os/signal" @@ -12,10 +15,12 @@ import ( "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/client" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/db" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/middlewares" + "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/model" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/storage" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/logger" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/server" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/services" + "github.com/go-jose/go-jose/v4" "github.com/google/uuid" "github.com/spf13/cobra" "github.com/zitadel/oidc/v3/pkg/op" @@ -53,9 +58,23 @@ func serve() { } backends := map[uuid.UUID]*client.OIDCClient{} + key := sha256.Sum256([]byte("test")) - st := storage.Storage{LocalStorage: userDB, InitializedBackends: backends} - opConf := op.Config{} + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + utils.Failf("Failed to generate private key: %s", err) + } + signingKey := model.Key{ + PrivateKey: privateKey, + KeyID: uuid.New(), + SigningAlg: jose.RS256, + } + + st := storage.Storage{LocalStorage: userDB, InitializedBackends: backends, Key: &signingKey} + opConf := op.Config{ + CryptoKey: key, + CodeMethodS256: false, + } slogger := slog.New(zapslog.NewHandler(logger.L.Desugar().Core(), nil)) // slogger := options := []op.Option{ diff --git a/polyculeconnect/go.mod b/polyculeconnect/go.mod index 2730293..223a046 100644 --- a/polyculeconnect/go.mod +++ b/polyculeconnect/go.mod @@ -14,7 +14,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.7.0 github.com/stretchr/testify v1.9.0 - github.com/zitadel/oidc/v3 v3.27.0 + github.com/zitadel/oidc/v3 v3.30.1 go.uber.org/zap v1.24.0 go.uber.org/zap/exp v0.2.0 ) @@ -68,25 +68,25 @@ require ( github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect github.com/prometheus/common v0.44.0 // indirect github.com/prometheus/procfs v0.11.1 // indirect - github.com/rs/cors v1.11.0 // indirect + github.com/rs/cors v1.11.1 // indirect github.com/russellhaering/goxmldsig v1.4.0 // indirect github.com/shopspring/decimal v1.2.0 // indirect github.com/spf13/cast v1.4.1 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/zitadel/logging v0.6.0 // indirect + github.com/zitadel/logging v0.6.1 // indirect github.com/zitadel/schema v1.3.0 // indirect go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/otel v1.28.0 // indirect - go.opentelemetry.io/otel/metric v1.28.0 // indirect - go.opentelemetry.io/otel/trace v1.28.0 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect go.uber.org/atomic v1.10.0 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/crypto v0.25.0 // indirect golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 // indirect golang.org/x/net v0.26.0 // indirect - golang.org/x/oauth2 v0.22.0 // indirect + golang.org/x/oauth2 v0.23.0 // indirect golang.org/x/sys v0.22.0 // indirect - golang.org/x/text v0.16.0 // indirect + golang.org/x/text v0.18.0 // indirect google.golang.org/api v0.150.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405 // indirect google.golang.org/grpc v1.59.0 // indirect diff --git a/polyculeconnect/go.sum b/polyculeconnect/go.sum index 99d4fb1..a322aa1 100644 --- a/polyculeconnect/go.sum +++ b/polyculeconnect/go.sum @@ -177,8 +177,8 @@ github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= -github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys= github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -210,20 +210,20 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/zitadel/logging v0.6.0 h1:t5Nnt//r+m2ZhhoTmoPX+c96pbMarqJvW1Vq6xFTank= -github.com/zitadel/logging v0.6.0/go.mod h1:Y4CyAXHpl3Mig6JOszcV5Rqqsojj+3n7y2F591Mp/ow= -github.com/zitadel/oidc/v3 v3.27.0 h1:zeYpyRH0UcgdCjVHUYzSsqf1jbSwVMPVxYKOnRXstgU= -github.com/zitadel/oidc/v3 v3.27.0/go.mod h1:ZwBEqSviCpJVZiYashzo53bEGRGXi7amE5Q8PpQg9IM= +github.com/zitadel/logging v0.6.1 h1:Vyzk1rl9Kq9RCevcpX6ujUaTYFX43aa4LkvV1TvUk+Y= +github.com/zitadel/logging v0.6.1/go.mod h1:Y4CyAXHpl3Mig6JOszcV5Rqqsojj+3n7y2F591Mp/ow= +github.com/zitadel/oidc/v3 v3.30.1 h1:CCi9qDjleuRYbECfUoVKrrN97KdheNCHAs33X8XnIRg= +github.com/zitadel/oidc/v3 v3.30.1/go.mod h1:N5p02vx+mLUwf+WFNpDsNp+8DS8+jlgFBwpz7NIQjrg= github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0= github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= -go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= -go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= -go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= -go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= -go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= @@ -265,16 +265,16 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= -golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= +golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -303,8 +303,8 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= diff --git a/polyculeconnect/internal/db/authcode/authcode.go b/polyculeconnect/internal/db/authcode/authcode.go new file mode 100644 index 0000000..f3a9084 --- /dev/null +++ b/polyculeconnect/internal/db/authcode/authcode.go @@ -0,0 +1,62 @@ +package authcode + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/model" + "git.faercol.me/faercol/polyculeconnect/polyculeconnect/logger" +) + +var ErrNotFound = errors.New("auth code not found") + +type AuthCodeDB interface { + GetAuthCodeByCode(ctx context.Context, code string) (*model.AuthCode, error) + CreateAuthCode(ctx context.Context, code model.AuthCode) error +} + +type sqlAuthCodeDB struct { + db *sql.DB +} + +func (db *sqlAuthCodeDB) CreateAuthCode(ctx context.Context, code model.AuthCode) error { + logger.L.Debugf("Creating auth code for request %s", code.RequestID) + tx, err := db.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to start transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + query := `INSERT INTO "auth_code_2" ("id", "auth_request_id", "code") VALUES ($1, $2, $3)` + _, err = tx.ExecContext(ctx, query, code.CodeID, code.RequestID, code.Code) + if err != nil { + return fmt.Errorf("failed to insert in DB: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + return nil +} + +func (db *sqlAuthCodeDB) GetAuthCodeByCode(ctx context.Context, code string) (*model.AuthCode, error) { + logger.L.Debugf("Getting auth code %s from DB", code) + query := `SELECT "id", "auth_request_id", "code" FROM "auth_code_2" WHERE "code" = ?` + row := db.db.QueryRowContext(ctx, query, code) + + var res model.AuthCode + if err := row.Scan(&res.CodeID, &res.RequestID, &res.Code); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("failed to read row from DB: %w", err) + } + + return &res, nil +} + +func New(db *sql.DB) AuthCodeDB { + return &sqlAuthCodeDB{db: db} +} diff --git a/polyculeconnect/internal/db/authrequest/authrequest.go b/polyculeconnect/internal/db/authrequest/authrequest.go index fd906ea..0b878a0 100644 --- a/polyculeconnect/internal/db/authrequest/authrequest.go +++ b/polyculeconnect/internal/db/authrequest/authrequest.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "time" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/model" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/logger" @@ -14,12 +15,13 @@ import ( var ErrNotFound = errors.New("backend not found") -const authRequestRows = `"id", "client_id", "backend_id", "scopes", "redirect_uri", "state", "nonce", "response_type", "creation_time", "done"` +const authRequestRows = `"id", "client_id", "backend_id", "scopes", "redirect_uri", "state", "nonce", "response_type", "creation_time", "done", "code_challenge", "code_challenge_method", "auth_time"` type AuthRequestDB interface { GetAuthRequestByID(ctx context.Context, id uuid.UUID) (*model.AuthRequest, error) CreateAuthRequest(ctx context.Context, req model.AuthRequest) error ValidateAuthRequest(ctx context.Context, reqID uuid.UUID) error + DeleteAuthRequest(ctx context.Context, reqID uuid.UUID) error } type sqlAuthRequestDB struct { @@ -34,10 +36,14 @@ func (db *sqlAuthRequestDB) GetAuthRequestByID(ctx context.Context, id uuid.UUID var res model.AuthRequest var scopesStr []byte - fmt.Println(query) - if err := row.Scan(&res.ID, &res.ClientID, &res.BackendID, &scopesStr, &res.RedirectURI, &res.State, &res.Nonce, &res.ResponseType, &res.CreationDate, &res.DoneVal); err != nil { + var timestamp *time.Time + + if err := row.Scan(&res.ID, &res.ClientID, &res.BackendID, &scopesStr, &res.RedirectURI, &res.State, &res.Nonce, &res.ResponseType, &res.CreationDate, &res.DoneVal, &res.CodeChallenge, &res.CodeChallengeMethod, ×tamp); err != nil { return nil, fmt.Errorf("failed to get auth request from DB: %w", err) } + if timestamp != nil { + res.AuthTime = *timestamp + } if err := json.Unmarshal(scopesStr, &res.Scopes); err != nil { return nil, fmt.Errorf("invalid format for scopes: %w", err) } @@ -59,11 +65,12 @@ func (db *sqlAuthRequestDB) CreateAuthRequest(ctx context.Context, req model.Aut } // TODO: when the old table is done, rename into auth_request - query := fmt.Sprintf(`INSERT INTO "auth_request_2" (%s) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, authRequestRows) + query := fmt.Sprintf(`INSERT INTO "auth_request_2" (%s) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULL)`, authRequestRows) _, err = tx.ExecContext(ctx, query, req.ID, req.ClientID, req.BackendID, scopesStr, req.RedirectURI, req.State, req.Nonce, req.ResponseType, req.CreationDate, false, + req.CodeChallenge, req.CodeChallengeMethod, ) if err != nil { return fmt.Errorf("failed to insert in DB: %w", err) @@ -84,7 +91,7 @@ func (db *sqlAuthRequestDB) ValidateAuthRequest(ctx context.Context, reqID uuid. } defer func() { _ = tx.Rollback() }() - res, err := tx.ExecContext(ctx, `UPDATE "auth_request_2" SET done = true WHERE id = $1`, reqID.String()) + res, err := tx.ExecContext(ctx, `UPDATE "auth_request_2" SET done = true, auth_time = $1 WHERE id = $2`, time.Now().UTC(), reqID.String()) if err != nil { return fmt.Errorf("failed to update in DB: %w", err) } @@ -103,6 +110,24 @@ func (db *sqlAuthRequestDB) ValidateAuthRequest(ctx context.Context, reqID uuid. return nil } +func (db *sqlAuthRequestDB) DeleteAuthRequest(ctx context.Context, reqID uuid.UUID) error { + logger.L.Debugf("Deleting auth request: %s", reqID) + tx, err := db.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to start transaction: %w", err) + } + defer func() { _ = tx.Rollback() }() + + _, err = tx.ExecContext(ctx, `DELETE FROM "auth_request_2" WHERE id = $1`, reqID.String()) + if err != nil { + return fmt.Errorf("failed to delete auth request: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + return nil +} + func New(db *sql.DB) *sqlAuthRequestDB { return &sqlAuthRequestDB{db: db} } diff --git a/polyculeconnect/internal/db/base.go b/polyculeconnect/internal/db/base.go index 207a520..6c0e7f8 100644 --- a/polyculeconnect/internal/db/base.go +++ b/polyculeconnect/internal/db/base.go @@ -5,6 +5,7 @@ import ( "fmt" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/config" + "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/db/authcode" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/db/authrequest" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/db/backend" "git.faercol.me/faercol/polyculeconnect/polyculeconnect/internal/db/client" @@ -15,6 +16,7 @@ type Storage interface { ClientStorage() client.ClientDB BackendStorage() backend.BackendDB AuthRequestStorage() authrequest.AuthRequestDB + AuthCodeStorage() authcode.AuthCodeDB } type sqlStorage struct { @@ -37,6 +39,10 @@ func (s *sqlStorage) AuthRequestStorage() authrequest.AuthRequestDB { return authrequest.New(s.db) } +func (s *sqlStorage) AuthCodeStorage() authcode.AuthCodeDB { + return authcode.New(s.db) +} + func New(conf config.AppConfig) (Storage, error) { db, err := sql.Open("sqlite3", conf.StorageConfig.File) if err != nil { diff --git a/polyculeconnect/internal/model/authcode.go b/polyculeconnect/internal/model/authcode.go new file mode 100644 index 0000000..4d896c6 --- /dev/null +++ b/polyculeconnect/internal/model/authcode.go @@ -0,0 +1,9 @@ +package model + +import "github.com/google/uuid" + +type AuthCode struct { + CodeID uuid.UUID + RequestID uuid.UUID + Code string +} diff --git a/polyculeconnect/internal/model/authrequest.go b/polyculeconnect/internal/model/authrequest.go index 65dd700..343a72e 100644 --- a/polyculeconnect/internal/model/authrequest.go +++ b/polyculeconnect/internal/model/authrequest.go @@ -56,7 +56,7 @@ func (a AuthRequest) GetAuthTime() time.Time { } func (a AuthRequest) GetClientID() string { - return a.ID.String() // small hack since we actually need the AuthRequestID here + return a.ClientID } func (a AuthRequest) GetCodeChallenge() *oidc.CodeChallenge { diff --git a/polyculeconnect/internal/model/client.go b/polyculeconnect/internal/model/client.go index d9b4c46..a7ae0d5 100644 --- a/polyculeconnect/internal/model/client.go +++ b/polyculeconnect/internal/model/client.go @@ -39,7 +39,7 @@ func (c Client) ApplicationType() op.ApplicationType { } func (c Client) AuthMethod() oidc.AuthMethod { - return oidc.AuthMethodNone + return oidc.AuthMethodBasic } func (c Client) ResponseTypes() []oidc.ResponseType { @@ -47,13 +47,13 @@ func (c Client) ResponseTypes() []oidc.ResponseType { } func (c Client) GrantTypes() []oidc.GrantType { - return []oidc.GrantType{oidc.GrantTypeCode} + return []oidc.GrantType{oidc.GrantTypeCode, oidc.GrantTypeRefreshToken, oidc.GrantTypeTokenExchange} } // LoginURL returns the login URL for a given client app and auth request. // This login url should be the authorization URL for the selected OIDC backend func (c Client) LoginURL(authRequestID string) string { - if c.AuthRequest == nil { + if authRequestID == "" { return "" // we don't have a request, let's return nothing } diff --git a/polyculeconnect/internal/model/signingkey.go b/polyculeconnect/internal/model/signingkey.go new file mode 100644 index 0000000..16a8fab --- /dev/null +++ b/polyculeconnect/internal/model/signingkey.go @@ -0,0 +1,57 @@ +package model + +import ( + "crypto/rsa" + "strings" + + "github.com/go-jose/go-jose/v4" + "github.com/google/uuid" +) + +type SigningKey struct { + PrivateKey *rsa.PrivateKey + KeyID uuid.UUID + Algorithm jose.SignatureAlgorithm +} + +func (k *SigningKey) ID() string { + return strings.ReplaceAll(k.KeyID.String(), "-", "") +} + +func (k *SigningKey) SignatureAlgorithm() jose.SignatureAlgorithm { + return k.Algorithm +} + +func (k *SigningKey) Key() any { + return k.PrivateKey +} + +type Key struct { + PrivateKey *rsa.PrivateKey + KeyID uuid.UUID + SigningAlg jose.SignatureAlgorithm +} + +func (k *Key) SigningKey() *SigningKey { + return &SigningKey{ + PrivateKey: k.PrivateKey, + KeyID: k.KeyID, + Algorithm: k.SigningAlg, + } +} + +func (k *Key) ID() string { + return strings.ReplaceAll(k.KeyID.String(), "-", "") +} + +func (k *Key) Algorithm() jose.SignatureAlgorithm { + return k.SigningAlg +} + +func (k *Key) Key() any { + return &k.PrivateKey.PublicKey +} + +func (k *Key) Use() string { + return "sig" +} diff --git a/polyculeconnect/internal/model/token.go b/polyculeconnect/internal/model/token.go new file mode 100644 index 0000000..afde439 --- /dev/null +++ b/polyculeconnect/internal/model/token.go @@ -0,0 +1,21 @@ +package model + +import ( + "time" + + "github.com/google/uuid" +) + +type Token struct { + ID uuid.UUID + RefreshTokenID uuid.UUID + Expiration time.Time + Subjet string + Audiences []string + Scopes []string +} + +type RefreshToken struct { + ID uuid.UUID + AuthTime time.Time +} diff --git a/polyculeconnect/internal/storage/storage.go b/polyculeconnect/internal/storage/storage.go index 7029074..44520f5 100644 --- a/polyculeconnect/internal/storage/storage.go +++ b/polyculeconnect/internal/storage/storage.go @@ -24,6 +24,7 @@ func ErrNotImplemented(name string) error { type Storage struct { LocalStorage db.Storage InitializedBackends map[uuid.UUID]*client.OIDCClient + Key *model.Key } /* @@ -33,7 +34,7 @@ func (s *Storage) CreateAuthRequest(ctx context.Context, req *oidc.AuthRequest, // userID should normally be an empty string (to verify), we don't get it in our workflow from what I saw // TODO: check this is indeed not needed / never present - logger.L.Debug("Creating a new auth request") + logger.L.Debugf("Creating a new auth request") // validate that the connector is correct backendName, ok := stringFromCtx(ctx, "backendName") @@ -69,24 +70,90 @@ func (s *Storage) AuthRequestByID(ctx context.Context, requestID string) (op.Aut } func (s *Storage) AuthRequestByCode(ctx context.Context, requestCode string) (op.AuthRequest, error) { - return nil, ErrNotImplemented("AuthRequestByCode") + logger.L.Debugf("Getting auth request from code %s", requestCode) + + authCode, err := s.LocalStorage.AuthCodeStorage().GetAuthCodeByCode(ctx, requestCode) + if err != nil { + return nil, fmt.Errorf("failed to get auth code from DB: %w", err) + } + + return s.LocalStorage.AuthRequestStorage().GetAuthRequestByID(ctx, authCode.RequestID) } func (s *Storage) SaveAuthCode(ctx context.Context, id string, code string) error { logger.L.Debugf("Saving auth code %s for request %s", code, id) - return ErrNotImplemented("SaveAuthCode") + + requestID, err := uuid.Parse(id) + if err != nil { + return fmt.Errorf("invalid requestID %s: %w", requestID, err) + } + + codeID := uuid.New() + + savedCode := model.AuthCode{ + CodeID: codeID, + RequestID: requestID, + Code: code, + } + return s.LocalStorage.AuthCodeStorage().CreateAuthCode(ctx, savedCode) } func (s *Storage) DeleteAuthRequest(ctx context.Context, id string) error { - return ErrNotImplemented("DeleteAuthRequest") + return nil // don't delete it for now, it seems we might need it????? (cc dex) + // reqID, err := uuid.Parse(id) + // if err != nil { + // return fmt.Errorf("invalid id format: %w", err) + // } + // return s.LocalStorage.AuthRequestStorage().DeleteAuthRequest(ctx, reqID) } func (s *Storage) CreateAccessToken(ctx context.Context, req op.TokenRequest) (accessTokenID string, expiration time.Time, err error) { - return "", time.Time{}, ErrNotImplemented("CreateAccessToken") + accessTokenUUID := uuid.New() + + // we are expecting our own request model + authRequest, ok := req.(*model.AuthRequest) + if !ok { + return "", time.Time{}, errors.New("failed to parse auth request") + } + + authTime := authRequest.AuthTime.UTC() + expiration = authTime.Add(5 * time.Minute) + + // token := model.Token{ + // ID: accessTokenUUID, + // RefreshTokenID: refreshTokenUUID, + // Expiration: authTime.Add(5 * time.Minute), + // Subjet: request.GetSubject(), + // Audiences: request.GetAudience(), + // Scopes: request.GetScopes(), + // } + + return accessTokenUUID.String(), expiration, nil } func (s *Storage) CreateAccessAndRefreshTokens(ctx context.Context, request op.TokenRequest, currentRefreshToken string) (accessTokenID string, newRefreshTokenID string, expiration time.Time, err error) { - return "", "", time.Time{}, ErrNotImplemented("CreateAccessAndRefreshTokens") + accessTokenUUID := uuid.New() + refreshTokenUUID := uuid.New() + + // we are expecting our own request model + authRequest, ok := request.(*model.AuthRequest) + if !ok { + return "", "", time.Time{}, errors.New("failed to parse auth request") + } + + authTime := authRequest.AuthTime.UTC() + expiration = authTime.Add(5 * time.Minute) + + // token := model.Token{ + // ID: accessTokenUUID, + // RefreshTokenID: refreshTokenUUID, + // Expiration: authTime.Add(5 * time.Minute), + // Subjet: request.GetSubject(), + // Audiences: request.GetAudience(), + // Scopes: request.GetScopes(), + // } + + return accessTokenUUID.String(), refreshTokenUUID.String(), expiration, nil } func (s *Storage) TokenRequestByRefreshToken(ctx context.Context, refreshTokenID string) (op.RefreshTokenRequest, error) { @@ -106,7 +173,7 @@ func (s *Storage) GetRefreshTokenInfo(ctx context.Context, clientID string, stok } func (s *Storage) SigningKey(ctx context.Context) (op.SigningKey, error) { - return nil, ErrNotImplemented("SigningKey") + return s.Key.SigningKey(), nil } func (s *Storage) SignatureAlgorithms(ctx context.Context) ([]jose.SignatureAlgorithm, error) { @@ -114,7 +181,7 @@ func (s *Storage) SignatureAlgorithms(ctx context.Context) ([]jose.SignatureAlgo } func (s *Storage) KeySet(ctx context.Context) ([]op.Key, error) { - return nil, ErrNotImplemented("KeySet") + return []op.Key{s.Key}, nil } /* @@ -169,11 +236,23 @@ func (s *Storage) GetClientByClientID(ctx context.Context, id string) (op.Client } func (s *Storage) AuthorizeClientIDSecret(ctx context.Context, clientID, clientSecret string) error { - return ErrNotImplemented("AuthorizeClientIDSecret") + logger.L.Debugf("Validating client secret %s for client %s", clientSecret, clientID) + + client, err := s.LocalStorage.ClientStorage().GetClientByID(ctx, clientID) + if err != nil { + return err + } + + if client.Secret != clientSecret { + return errors.New("invalid secret") + } + + return nil } func (s *Storage) SetUserinfoFromScopes(ctx context.Context, userinfo *oidc.UserInfo, userID, clientID string, scopes []string) error { - return ErrNotImplemented("SetUserinfoFromScopes") + // we'll use FromRequest instead + return nil } func (s *Storage) SetUserinfoFromToken(ctx context.Context, userinfo *oidc.UserInfo, tokenID, subject, origin string) error { @@ -185,7 +264,8 @@ func (s *Storage) SetIntrospectionFromToken(ctx context.Context, userinfo *oidc. } func (s *Storage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (map[string]interface{}, error) { - return nil, ErrNotImplemented("GetPrivateClaimsFromScopes") + // For now, let's just return nothing, we don't want to add any private scope + return nil, nil } func (s *Storage) GetKeyByIDAndClientID(ctx context.Context, keyID, clientID string) (*jose.JSONWebKey, error) { diff --git a/polyculeconnect/migrations/1_create_auth_request.up.sql b/polyculeconnect/migrations/1_create_auth_request.up.sql index 534cadc..803dc3b 100644 --- a/polyculeconnect/migrations/1_create_auth_request.up.sql +++ b/polyculeconnect/migrations/1_create_auth_request.up.sql @@ -7,5 +7,5 @@ CREATE TABLE "auth_request_2" ( state TEXT NOT NULL, nonce TEXT NOT NULL, response_type TEXT NOT NULL, - CREATION_TIME timestamp NOT NULL + creation_time timestamp NOT NULL ); \ No newline at end of file diff --git a/polyculeconnect/migrations/4_add_code_challenge.down.sql b/polyculeconnect/migrations/4_add_code_challenge.down.sql new file mode 100644 index 0000000..f3aa033 --- /dev/null +++ b/polyculeconnect/migrations/4_add_code_challenge.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE "auth_request_2" DROP COLUMN code_challenge; +ALTER TABLE "auth_request_2" DROP COLUMN code_challenge_method; diff --git a/polyculeconnect/migrations/4_add_code_challenge.up.sql b/polyculeconnect/migrations/4_add_code_challenge.up.sql new file mode 100644 index 0000000..bc8f9cd --- /dev/null +++ b/polyculeconnect/migrations/4_add_code_challenge.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE "auth_request_2" ADD COLUMN code_challenge STRING NOT NULL DEFAULT ''; +ALTER TABLE "auth_request_2" ADD COLUMN code_challenge_method STRING NOT NULL DEFAULT ''; diff --git a/polyculeconnect/migrations/5_add_auth_request_auth_time.down.sql b/polyculeconnect/migrations/5_add_auth_request_auth_time.down.sql new file mode 100644 index 0000000..fc5738d --- /dev/null +++ b/polyculeconnect/migrations/5_add_auth_request_auth_time.down.sql @@ -0,0 +1 @@ +ALTER TABLE "auth_request_2" DROP COLUMN auth_time; diff --git a/polyculeconnect/migrations/5_add_auth_request_auth_time.up.sql b/polyculeconnect/migrations/5_add_auth_request_auth_time.up.sql new file mode 100644 index 0000000..e8d84b0 --- /dev/null +++ b/polyculeconnect/migrations/5_add_auth_request_auth_time.up.sql @@ -0,0 +1 @@ +ALTER TABLE "auth_request_2" ADD COLUMN auth_time timestamp; diff --git a/polyculeconnect/polyculeconnect.db b/polyculeconnect/polyculeconnect.db index e4f8d2d8e545e076845d22c0a698be54b94d092d..46f8e1980435c6fd4d71fa135c07375e6b0a1e29 100644 GIT binary patch literal 151552 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 D?