diff --git a/admin_client.go b/admin_client.go index 9faf30d..f5baa92 100644 --- a/admin_client.go +++ b/admin_client.go @@ -151,12 +151,12 @@ func (c *AuthorizerAdminClient) execute(spec adminMethodSpec, out interface{}) e } if spec.restResponse != nil { msg := spec.restResponse() - if err := doREST(c.AuthorizerURL, spec.restMethod, spec.restPath, spec.restBody, c.ExtraHeaders, map[string]string{adminSecretHeader: c.AdminSecret}, msg); err != nil { + if err := doREST(nil, c.AuthorizerURL, spec.restMethod, spec.restPath, spec.restBody, c.ExtraHeaders, map[string]string{adminSecretHeader: c.AdminSecret}, msg); err != nil { return err } return unwrapProto(msg, spec.responseUnwrap, out) } - return doREST(c.AuthorizerURL, spec.restMethod, spec.restPath, spec.restBody, c.ExtraHeaders, map[string]string{adminSecretHeader: c.AdminSecret}, out) + return doREST(nil, c.AuthorizerURL, spec.restMethod, spec.restPath, spec.restBody, c.ExtraHeaders, map[string]string{adminSecretHeader: c.AdminSecret}, out) case ProtocolGRPC: if spec.grpcCall == nil { diff --git a/client.go b/client.go index 00f1f32..41afad8 100644 --- a/client.go +++ b/client.go @@ -4,7 +4,13 @@ package authorizer import ( "fmt" + "net/http" + "net/http/cookiejar" + "net/url" "strings" + "time" + + "google.golang.org/grpc" ) // AuthorizerClient defines the attributes required to initiate authorizer client @@ -19,6 +25,48 @@ type AuthorizerClient struct { // GRPCEndpoint overrides the host:port dialed when Protocol is grpc. When // empty it is derived from AuthorizerURL using the gRPC default port. GRPCEndpoint string + + // httpClient is shared across every call on this client and carries a + // cookie jar. The jar is REQUIRED, not an optimisation: the MFA offer flow + // identifies the pending user by a session cookie the server sets on + // signup/login, and SkipMfaSetup / VerifyOtp only resolve it if that cookie + // is sent back. Building a fresh http.Client per call, as this SDK used to, + // dropped the cookie and made those calls fail with "invalid session" — + // i.e. the whole MFA surface was unreachable from Go. + httpClient *http.Client +} + +// newHTTPClient builds the shared cookie-aware client. cookiejar.New with a nil +// options value never returns an error, but the error is handled rather than +// ignored so a future options change cannot silently produce a jar-less client. +func newHTTPClient() *http.Client { + jar, err := cookiejar.New(nil) + if err != nil { + return &http.Client{Timeout: 30 * time.Second} + } + return &http.Client{Timeout: 30 * time.Second, Jar: jar} +} + +// HTTPClient returns the shared cookie-aware http client, initialising it on +// first use so a zero-value AuthorizerClient (or one built by an older +// constructor path) still carries a jar. +func (c *AuthorizerClient) HTTPClient() *http.Client { + if c.httpClient == nil { + c.httpClient = newHTTPClient() + } + return c.httpClient +} + +// dialGRPC opens a gRPC connection whose calls share this client's cookie jar, +// so a session established over gRPC (or over HTTP) survives the next call. +func (c *AuthorizerClient) dialGRPC() (*grpc.ClientConn, error) { + jar := c.HTTPClient().Jar + u, err := url.Parse(c.AuthorizerURL) + if jar == nil || err != nil || u.Host == "" { + return grpcDial(c.AuthorizerURL, c.GRPCEndpoint) + } + return grpcDial(c.AuthorizerURL, c.GRPCEndpoint, + grpc.WithChainUnaryInterceptor(cookieInterceptor(jar, u))) } // ClientOption customizes an AuthorizerClient at construction time. @@ -76,6 +124,7 @@ func NewAuthorizerClient(clientID, authorizerURL, redirectURL string, extraHeade ClientID: clientID, ExtraHeaders: headers, Protocol: ProtocolGraphQL, + httpClient: newHTTPClient(), } for _, opt := range opts { opt(c) diff --git a/cookie_jar_test.go b/cookie_jar_test.go new file mode 100644 index 0000000..453ebc1 --- /dev/null +++ b/cookie_jar_test.go @@ -0,0 +1,162 @@ +package authorizer + +import ( + "context" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +// TestClientPersistsCookiesAcrossCalls is the regression test for the MFA +// offer flow. +// +// Since server 2.4.0, MFA is on by default: signup/login withhold the access +// token and return "Proceed to mfa setup", identifying the pending user by a +// session cookie. SkipMfaSetup and VerifyOtp resolve that user ONLY if the +// cookie is sent back. +// +// This SDK previously built a fresh http.Client per call, so the cookie was +// dropped between them and every one of those calls failed with "invalid +// session" — the entire MFA surface was unreachable from Go, while the methods +// existed and looked correct. +func TestClientPersistsCookiesAcrossCalls(t *testing.T) { + var secondRequestCookie string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie("mfa_session"); err == nil { + secondRequestCookie = c.Value + } + http.SetCookie(w, &http.Cookie{Name: "mfa_session", Value: "session-abc", Path: "/"}) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"ok":true}}`)) + })) + defer srv.Close() + + c, err := NewAuthorizerClient("test-client", srv.URL, "", nil) + if err != nil { + t.Fatalf("new client: %v", err) + } + + // First call receives the cookie. + if _, err := c.ExecuteGraphQL(&GraphQLRequest{Query: "{ __typename }"}, nil); err != nil { + t.Fatalf("first call: %v", err) + } + // Second call must send it back. + if _, err := c.ExecuteGraphQL(&GraphQLRequest{Query: "{ __typename }"}, nil); err != nil { + t.Fatalf("second call: %v", err) + } + + if secondRequestCookie != "session-abc" { + t.Fatalf("cookie not replayed on the second call (got %q) — "+ + "SkipMfaSetup/VerifyOtp will fail with \"invalid session\"", secondRequestCookie) + } +} + +// TestRESTTransportPersistsCookies pins the same contract for the REST +// transport, which kept using http.DefaultClient — jar-less — after the jar was +// added, so MFA over rest failed with "invalid session" while graphql worked. +func TestRESTTransportPersistsCookies(t *testing.T) { + var secondRequestCookie string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie("mfa_session"); err == nil { + secondRequestCookie = c.Value + } + http.SetCookie(w, &http.Cookie{Name: "mfa_session", Value: "session-abc", Path: "/"}) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + c, err := NewAuthorizerClient("test-client", srv.URL, "", nil, WithProtocol(ProtocolREST)) + if err != nil { + t.Fatalf("new client: %v", err) + } + for i := 0; i < 2; i++ { + if err := c.executeREST(http.MethodPost, "/v1/signup", map[string]string{}, nil, nil); err != nil { + t.Fatalf("rest call %d: %v", i, err) + } + } + + if secondRequestCookie != "session-abc" { + t.Fatalf("cookie not replayed on the second REST call (got %q)", secondRequestCookie) + } +} + +// TestGRPCCookieInterceptor pins the gRPC half of the same contract: the +// server hands cookies out as `set-cookie` header metadata and expects them +// back as a `cookie` entry, so without this the MFA flow is unreachable over +// gRPC. The invoker is stubbed — the contract under test is metadata handling, +// not the wire. +func TestGRPCCookieInterceptor(t *testing.T) { + u, _ := url.Parse("http://authorizer.test") + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatalf("jar: %v", err) + } + intercept := cookieInterceptor(jar, u) + + // First call: the server sets a cookie, the client sends none. + var sentFirst []string + err = intercept(context.Background(), "/Signup", nil, nil, nil, + func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, opts ...grpc.CallOption) error { + md, _ := metadata.FromOutgoingContext(ctx) + sentFirst = md.Get("cookie") + setHeader(opts, metadata.Pairs("set-cookie", "mfa_session=session-abc; Path=/")) + return nil + }) + if err != nil { + t.Fatalf("first call: %v", err) + } + if len(sentFirst) != 0 { + t.Errorf("expected no cookie metadata on the first call, got %v", sentFirst) + } + + // Second call: the stored cookie must be replayed. + var sentSecond []string + err = intercept(context.Background(), "/SkipMfaSetup", nil, nil, nil, + func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, opts ...grpc.CallOption) error { + md, _ := metadata.FromOutgoingContext(ctx) + sentSecond = md.Get("cookie") + return nil + }) + if err != nil { + t.Fatalf("second call: %v", err) + } + if len(sentSecond) != 1 || !strings.Contains(sentSecond[0], "mfa_session=session-abc") { + t.Fatalf("cookie not replayed over grpc (got %v) — SkipMfaSetup will fail with \"invalid session\"", sentSecond) + } +} + +// setHeader writes response header metadata the way a real grpc call does, by +// filling the grpc.Header(&md) call option the interceptor appended. +func setHeader(opts []grpc.CallOption, md metadata.MD) { + for _, o := range opts { + if h, ok := o.(grpc.HeaderCallOption); ok { + *h.HeaderAddr = md + } + } +} + +// TestHTTPClientAlwaysHasAJar pins that no construction path yields a +// jar-less client, including a zero-value struct built without the constructor. +func TestHTTPClientAlwaysHasAJar(t *testing.T) { + c, err := NewAuthorizerClient("test-client", "http://localhost:8080", "", nil) + if err != nil { + t.Fatalf("new client: %v", err) + } + if c.HTTPClient().Jar == nil { + t.Fatal("constructor produced a client with no cookie jar") + } + + var zero AuthorizerClient + if zero.HTTPClient().Jar == nil { + t.Fatal("zero-value client must lazily gain a cookie jar") + } +} diff --git a/execute.go b/execute.go index 47d12c2..b58bcea 100644 --- a/execute.go +++ b/execute.go @@ -91,7 +91,7 @@ func (c *AuthorizerClient) execute(spec methodSpec, headers map[string]string, o if spec.grpcCall == nil { return unsupportedProtocol(spec.name, c.Protocol, spec.supported()) } - conn, err := grpcDial(c.AuthorizerURL, c.GRPCEndpoint) + conn, err := c.dialGRPC() if err != nil { return err } diff --git a/get_token.go b/get_token.go index 23919d4..cd8d326 100644 --- a/get_token.go +++ b/get_token.go @@ -8,7 +8,6 @@ import ( "net/http" "net/url" "strings" - "time" ) // GetTokenRequest defines attributes for token request. Only the set (non-nil) @@ -116,7 +115,7 @@ func (c *AuthorizerClient) GetToken(req *GetTokenRequest) (*TokenResponse, error } } - client := http.Client{Timeout: 30 * time.Second} + client := c.HTTPClient() res, err := client.Do(httpReq) if err != nil { return nil, err diff --git a/graphql.go b/graphql.go index f329d27..a14fc2b 100644 --- a/graphql.go +++ b/graphql.go @@ -7,7 +7,6 @@ import ( "io" "net/http" "net/url" - "time" ) // GraphQLRequest is object used to make graphql queries @@ -32,7 +31,7 @@ func (c *AuthorizerClient) ExecuteGraphQL(req *GraphQLRequest, headers map[strin return nil, err } - client := http.Client{Timeout: 30 * time.Second} + client := c.HTTPClient() httpReq, err := http.NewRequest(http.MethodPost, c.AuthorizerURL+"/graphql", bytes.NewReader(jsonReq)) if err != nil { return nil, err diff --git a/protocol.go b/protocol.go index c0caf01..9b5d894 100644 --- a/protocol.go +++ b/protocol.go @@ -4,12 +4,14 @@ import ( "context" "crypto/tls" "fmt" + "net/http" "net/url" "strings" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" ) // Protocol selects the wire transport a client uses to talk to authorizer. @@ -40,7 +42,7 @@ const defaultGRPCPort = "9091" // not the HTTP URL's port. An https:// URL (or an explicit :443 host) uses TLS; // everything else dials insecurely, matching the typical self-hosted // http://host:8080 deployment. -func grpcDial(authorizerURL, grpcEndpoint string) (*grpc.ClientConn, error) { +func grpcDial(authorizerURL, grpcEndpoint string, opts ...grpc.DialOption) (*grpc.ClientConn, error) { u, err := url.Parse(authorizerURL) if err != nil { return nil, fmt.Errorf("invalid authorizerURL %q: %w", authorizerURL, err) @@ -67,7 +69,36 @@ func grpcDial(authorizerURL, grpcEndpoint string) (*grpc.ClientConn, error) { host += ":443" } - return grpc.NewClient(host, grpc.WithTransportCredentials(creds)) + return grpc.NewClient(host, append([]grpc.DialOption{grpc.WithTransportCredentials(creds)}, opts...)...) +} + +// cookieInterceptor carries the session cookie across gRPC calls. gRPC has no +// cookie concept, so the server sends its cookies as `set-cookie` header +// metadata and reads them back from a `cookie` metadata entry. Without this the +// MFA offer flow is unreachable over gRPC for the same reason it was over +// HTTP before the jar existed: signup/login hand out an MFA session the next +// call never replays, and SkipMfaSetup/VerifyOtp answer "invalid session". +// Cookies are stored in the client's shared jar, so a session started over one +// protocol is usable from another. +func cookieInterceptor(jar http.CookieJar, u *url.URL) grpc.UnaryClientInterceptor { + return func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error { + if cookies := jar.Cookies(u); len(cookies) > 0 { + pairs := make([]string, 0, len(cookies)) + for _, c := range cookies { + pairs = append(pairs, c.Name+"="+c.Value) + } + ctx = metadata.AppendToOutgoingContext(ctx, "cookie", strings.Join(pairs, "; ")) + } + + var header metadata.MD + err := invoker(ctx, method, req, reply, cc, append(opts, grpc.Header(&header))...) + // Store cookies even when the call failed: a rejected MFA attempt can + // still rotate the session. + if set := header.Get("set-cookie"); len(set) > 0 { + jar.SetCookies(u, (&http.Response{Header: http.Header{"Set-Cookie": set}}).Cookies()) + } + return err + } } // stripPort removes a trailing :port from host, leaving the bare host. diff --git a/revoke_token.go b/revoke_token.go index a33da74..4f2a89b 100644 --- a/revoke_token.go +++ b/revoke_token.go @@ -29,7 +29,7 @@ func (c *AuthorizerClient) RevokeToken(req *RevokeTokenInput) (*Response, error) return nil, err } - client := http.Client{} + client := c.HTTPClient() httpReq, err := http.NewRequest(http.MethodPost, c.AuthorizerURL+"/oauth/revoke", bytes.NewReader(jsonReq)) if err != nil { return nil, err diff --git a/test/authorizer_test.go b/test/authorizer_test.go index 1a5e6b0..70676d3 100644 --- a/test/authorizer_test.go +++ b/test/authorizer_test.go @@ -54,6 +54,68 @@ func uniqueEmail() string { return fmt.Sprintf("test-%d@yopmail.com", rand.Int63()) } +// boolValue dereferences an optional bool flag, defaulting to false. +func boolValue(b *bool) bool { return b != nil && *b } + +// isMFAOffer reports whether an auth response is server 2.4.0's token-withheld +// MFA offer: MFA is on by default, so signup/login answer with +// "Proceed to mfa setup" plus the should_show_totp_screen / should_offer_* +// flags and NO access token. The token is only issued once the user enrolls a +// factor or explicitly declines via skip_mfa_setup. +func isMFAOffer(res *authorizer.AuthTokenResponse) bool { + return res != nil && + authorizer.StringValue(res.AccessToken) == "" && + (boolValue(res.ShouldShowTotpScreen) || + boolValue(res.ShouldOfferWebauthnMfaSetup) || + boolValue(res.ShouldOfferEmailOtpMfaSetup) || + boolValue(res.ShouldOfferSmsOtpMfaSetup)) +} + +// resolveMFAOffer turns a token-withheld MFA offer into a real auth response by +// declining the offer, which is what a client that does not want to enroll a +// second factor must do. It MUST run on the same client that made the +// signup/login call: the pending user is identified by the MFA session cookie +// that call received (gRPC carries it as `set-cookie` metadata). +// +// A response that is not an MFA offer is returned untouched, so a token that is +// missing for any other reason still fails the caller's own assertion. +func resolveMFAOffer(t *testing.T, c *authorizer.AuthorizerClient, email string, res *authorizer.AuthTokenResponse) *authorizer.AuthTokenResponse { + t.Helper() + if !isMFAOffer(res) { + return res + } + skipped, err := c.SkipMfaSetup(&authorizer.SkipMfaSetupRequest{Email: &email}) + if err != nil { + t.Fatalf("SkipMfaSetup failed while resolving the MFA offer for %s: %v", email, err) + } + return skipped +} + +// signUp creates a user and returns an auth response that carries the access +// token, resolving the default MFA offer on the way. +func signUp(t *testing.T, c *authorizer.AuthorizerClient, email string) *authorizer.AuthTokenResponse { + t.Helper() + res, err := c.SignUp(&authorizer.SignUpRequest{ + Email: &email, + Password: testPassword, + ConfirmPassword: testPassword, + }) + if err != nil { + t.Fatalf("SignUp failed: %v", err) + } + return resolveMFAOffer(t, c, email, res) +} + +// login authenticates an existing user, resolving the default MFA offer. +func login(t *testing.T, c *authorizer.AuthorizerClient, email string) *authorizer.AuthTokenResponse { + t.Helper() + res, err := c.Login(&authorizer.LoginRequest{Email: &email, Password: testPassword}) + if err != nil { + t.Fatalf("Login failed: %v", err) + } + return resolveMFAOffer(t, c, email, res) +} + func TestGetMetaData(t *testing.T) { c := testClient(t) @@ -99,22 +161,9 @@ func TestLogin(t *testing.T) { email := uniqueEmail() // Sign up first to create a user - _, err := c.SignUp(&authorizer.SignUpRequest{ - Email: &email, - Password: testPassword, - ConfirmPassword: testPassword, - }) - if err != nil { - t.Fatalf("SignUp failed (prerequisite for Login): %v", err) - } + signUp(t, c, email) - res, err := c.Login(&authorizer.LoginRequest{ - Email: &email, - Password: testPassword, - }) - if err != nil { - t.Fatalf("Login failed: %v", err) - } + res := login(t, c, email) if res == nil { t.Fatal("Login returned nil response") @@ -129,22 +178,8 @@ func TestGetProfile(t *testing.T) { email := uniqueEmail() // Sign up and login first - _, err := c.SignUp(&authorizer.SignUpRequest{ - Email: &email, - Password: testPassword, - ConfirmPassword: testPassword, - }) - if err != nil { - t.Fatalf("SignUp failed (prerequisite): %v", err) - } - - loginRes, err := c.Login(&authorizer.LoginRequest{ - Email: &email, - Password: testPassword, - }) - if err != nil { - t.Fatalf("Login failed (prerequisite): %v", err) - } + signUp(t, c, email) + loginRes := login(t, c, email) res, err := c.GetProfile(map[string]string{ "Authorization": fmt.Sprintf("Bearer %s", authorizer.StringValue(loginRes.AccessToken)), @@ -165,22 +200,8 @@ func TestGetSession(t *testing.T) { c := testClient(t) email := uniqueEmail() - _, err := c.SignUp(&authorizer.SignUpRequest{ - Email: &email, - Password: testPassword, - ConfirmPassword: testPassword, - }) - if err != nil { - t.Fatalf("SignUp failed (prerequisite): %v", err) - } - - loginRes, err := c.Login(&authorizer.LoginRequest{ - Email: &email, - Password: testPassword, - }) - if err != nil { - t.Fatalf("Login failed (prerequisite): %v", err) - } + signUp(t, c, email) + loginRes := login(t, c, email) res, err := c.GetSession(&authorizer.SessionQueryRequest{ Roles: []*string{}, @@ -204,22 +225,8 @@ func TestLogout(t *testing.T) { c := testClient(t) email := uniqueEmail() - _, err := c.SignUp(&authorizer.SignUpRequest{ - Email: &email, - Password: testPassword, - ConfirmPassword: testPassword, - }) - if err != nil { - t.Fatalf("SignUp failed (prerequisite): %v", err) - } - - loginRes, err := c.Login(&authorizer.LoginRequest{ - Email: &email, - Password: testPassword, - }) - if err != nil { - t.Fatalf("Login failed (prerequisite): %v", err) - } + signUp(t, c, email) + loginRes := login(t, c, email) res, err := c.Logout(map[string]string{ "Authorization": fmt.Sprintf("Bearer %s", authorizer.StringValue(loginRes.AccessToken)), @@ -240,22 +247,8 @@ func TestValidateJWTToken(t *testing.T) { c := testClient(t) email := uniqueEmail() - _, err := c.SignUp(&authorizer.SignUpRequest{ - Email: &email, - Password: testPassword, - ConfirmPassword: testPassword, - }) - if err != nil { - t.Fatalf("SignUp failed (prerequisite): %v", err) - } - - loginRes, err := c.Login(&authorizer.LoginRequest{ - Email: &email, - Password: testPassword, - }) - if err != nil { - t.Fatalf("Login failed (prerequisite): %v", err) - } + signUp(t, c, email) + loginRes := login(t, c, email) res, err := c.ValidateJWTToken(&authorizer.ValidateJWTTokenRequest{ TokenType: authorizer.TokenTypeAccessToken, @@ -395,22 +388,8 @@ func TestCheckPermissions(t *testing.T) { c := testClient(t) email := uniqueEmail() - _, err := c.SignUp(&authorizer.SignUpRequest{ - Email: &email, - Password: testPassword, - ConfirmPassword: testPassword, - }) - if err != nil { - t.Fatalf("SignUp failed (prerequisite): %v", err) - } - - loginRes, err := c.Login(&authorizer.LoginRequest{ - Email: &email, - Password: testPassword, - }) - if err != nil { - t.Fatalf("Login failed (prerequisite): %v", err) - } + signUp(t, c, email) + loginRes := login(t, c, email) headers := map[string]string{ "Authorization": fmt.Sprintf("Bearer %s", authorizer.StringValue(loginRes.AccessToken)), @@ -448,22 +427,8 @@ func TestListPermissions(t *testing.T) { c := testClient(t) email := uniqueEmail() - _, err := c.SignUp(&authorizer.SignUpRequest{ - Email: &email, - Password: testPassword, - ConfirmPassword: testPassword, - }) - if err != nil { - t.Fatalf("SignUp failed (prerequisite): %v", err) - } - - loginRes, err := c.Login(&authorizer.LoginRequest{ - Email: &email, - Password: testPassword, - }) - if err != nil { - t.Fatalf("Login failed (prerequisite): %v", err) - } + signUp(t, c, email) + loginRes := login(t, c, email) headers := map[string]string{ "Authorization": fmt.Sprintf("Bearer %s", authorizer.StringValue(loginRes.AccessToken)), diff --git a/test/protocol_test.go b/test/protocol_test.go index 997753e..ad4fb92 100644 --- a/test/protocol_test.go +++ b/test/protocol_test.go @@ -58,14 +58,7 @@ func TestSignUpProfileAcrossProtocols(t *testing.T) { c := protocolClient(t, p) email := uniqueEmail() - signupRes, err := c.SignUp(&authorizer.SignUpRequest{ - Email: &email, - Password: testPassword, - ConfirmPassword: testPassword, - }) - if err != nil { - t.Fatalf("[%s] SignUp failed: %v", p, err) - } + signupRes := signUp(t, c, email) if signupRes == nil || signupRes.AccessToken == nil || *signupRes.AccessToken == "" { t.Fatalf("[%s] SignUp: expected non-empty access_token, got %+v", p, signupRes) } @@ -98,19 +91,10 @@ func TestLoginAcrossProtocols(t *testing.T) { email := uniqueEmail() // Signup over graphql so every protocol's Login has a fresh account. gql := protocolClient(t, authorizer.ProtocolGraphQL) - if _, err := gql.SignUp(&authorizer.SignUpRequest{ - Email: &email, - Password: testPassword, - ConfirmPassword: testPassword, - }); err != nil { - t.Fatalf("SignUp failed: %v", err) - } + signUp(t, gql, email) c := protocolClient(t, p) - loginRes, err := c.Login(&authorizer.LoginRequest{Email: &email, Password: testPassword}) - if err != nil { - t.Fatalf("[%s] Login failed: %v", p, err) - } + loginRes := login(t, c, email) if loginRes == nil || loginRes.AccessToken == nil || *loginRes.AccessToken == "" { t.Fatalf("[%s] Login: expected non-empty access_token, got %+v", p, loginRes) } @@ -183,14 +167,7 @@ func TestUpdateProfileAcrossProtocols(t *testing.T) { t.Run(string(p), func(t *testing.T) { email := uniqueEmail() c := protocolClient(t, p) - signupRes, err := c.SignUp(&authorizer.SignUpRequest{ - Email: &email, - Password: testPassword, - ConfirmPassword: testPassword, - }) - if err != nil { - t.Fatalf("[%s] SignUp failed: %v", p, err) - } + signupRes := signUp(t, c, email) authHeader := map[string]string{ "Authorization": fmt.Sprintf("Bearer %s", authorizer.StringValue(signupRes.AccessToken)), } diff --git a/transport.go b/transport.go index e1c6ba4..2e15d9f 100644 --- a/transport.go +++ b/transport.go @@ -35,11 +35,15 @@ func outgoingContext(ctx context.Context, headers map[string]string) context.Con // // The Origin header is auto-injected for the same CSRF reason as ExecuteGraphQL. func (c *AuthorizerClient) executeREST(method, path string, body interface{}, perCallHeaders map[string]string, out interface{}) error { - return doREST(c.AuthorizerURL, method, path, body, c.ExtraHeaders, perCallHeaders, out) + // HTTPClient(), not http.DefaultClient: the REST transport must carry the + // same cookie jar as the GraphQL one, or the MFA session cookie set by + // signup/login is dropped and SkipMfaSetup/VerifyOtp fail with + // "invalid session". + return doREST(c.HTTPClient(), c.AuthorizerURL, method, path, body, c.ExtraHeaders, perCallHeaders, out) } // doREST is the shared REST executor used by both the user and admin clients. -func doREST(baseURL, method, path string, body interface{}, extraHeaders, perCallHeaders map[string]string, out interface{}) error { +func doREST(client *http.Client, baseURL, method, path string, body interface{}, extraHeaders, perCallHeaders map[string]string, out interface{}) error { var reqBody io.Reader if method == http.MethodPost && body != nil { jsonReq, err := json.Marshal(body) @@ -70,7 +74,10 @@ func doREST(baseURL, method, path string, body interface{}, extraHeaders, perCal } } - res, err := http.DefaultClient.Do(httpReq) + if client == nil { + client = http.DefaultClient + } + res, err := client.Do(httpReq) if err != nil { return err }