Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
234 lines
7.3 KiB
Go
234 lines
7.3 KiB
Go
// Copyright © 2026 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package login
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"github.com/gofrs/uuid"
|
|
"github.com/pkg/errors"
|
|
|
|
"github.com/ory/herodot"
|
|
"github.com/ory/kratos/selfservice/flow"
|
|
"github.com/ory/kratos/x"
|
|
"github.com/ory/nosurf"
|
|
)
|
|
|
|
const (
|
|
// RouteAdminCreateTestFlow is the admin route for creating a dry-run OIDC
|
|
// test login flow. The admin router prepends /admin automatically, so the
|
|
// full path is POST /admin/test-login-flows.
|
|
RouteAdminCreateTestFlow = "/test-login-flows"
|
|
|
|
// RouteDeleteTestFlow is the public route for deleting a test login flow.
|
|
// It is authorized by the flow's CSRF bearer token, not by a session.
|
|
RouteDeleteTestFlow = "/self-service/login/test"
|
|
|
|
// maxProviderIDLength bounds the provider_id payload field. Provider IDs
|
|
// are short identifiers; anything longer indicates user error or abuse.
|
|
maxProviderIDLength = 255
|
|
)
|
|
|
|
// TestStrategy populates a login flow's UI with a single submit button
|
|
// for the given OIDC provider. The OIDC strategy implements this interface.
|
|
// Defined in the login package to avoid an import cycle.
|
|
type TestStrategy interface {
|
|
PopulateTestLoginFlow(r *http.Request, f *Flow, providerID string) error
|
|
}
|
|
|
|
// TestStrategyProvider exposes the enabled TestStrategy for the
|
|
// current request context.
|
|
type TestStrategyProvider interface {
|
|
TestStrategy(ctx context.Context) TestStrategy
|
|
}
|
|
|
|
// Parameters for creating a test OIDC login flow.
|
|
//
|
|
// swagger:parameters createTestLoginFlow
|
|
//
|
|
//nolint:deadcode,unused
|
|
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
|
type createTestLoginFlow struct {
|
|
// in: body
|
|
// required: true
|
|
Body createTestLoginFlowBody
|
|
}
|
|
|
|
// Request body for creating a test OIDC login flow.
|
|
//
|
|
// swagger:model createTestLoginFlowBody
|
|
type createTestLoginFlowBody struct {
|
|
// ID of the OIDC provider to test. Must match a provider configured on
|
|
// the project that serves this request.
|
|
//
|
|
// required: true
|
|
// maxLength: 255
|
|
ProviderID string `json:"provider_id"`
|
|
}
|
|
|
|
// swagger:route POST /admin/test-login-flows identity createTestLoginFlow
|
|
//
|
|
// # Create a test OIDC login flow
|
|
//
|
|
// Creates a dry-run OIDC test login flow pre-scoped to one provider. The
|
|
// returned flow carries a single-submit UI and a CSRF bearer token. No
|
|
// identity is persisted and no session is issued when the flow completes;
|
|
// the captured debug data is returned in the flow's test_context.
|
|
//
|
|
// Consumes:
|
|
// - application/json
|
|
//
|
|
// Produces:
|
|
// - application/json
|
|
//
|
|
// Schemes: http, https
|
|
//
|
|
// Responses:
|
|
// 201: loginFlow
|
|
// 400: errorGeneric
|
|
// 404: errorGeneric
|
|
// default: errorGeneric
|
|
//
|
|
// Extensions:
|
|
// x-ory-ratelimit-bucket: kratos-admin-low
|
|
func (h *Handler) adminCreateTestLoginFlow(w http.ResponseWriter, r *http.Request) {
|
|
// The strategy annotates this span with the provider lookup outcome
|
|
// (test_login_flow.* attributes); without it, a failed lookup is
|
|
// invisible in traces because the whole path is in-memory.
|
|
ctx, span := h.d.Tracer(r.Context()).Tracer().Start(r.Context(), "login.Handler.adminCreateTestLoginFlow")
|
|
defer span.End()
|
|
r = r.WithContext(ctx)
|
|
|
|
var body createTestLoginFlowBody
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReason("could not parse request body")))
|
|
return
|
|
}
|
|
if body.ProviderID == "" {
|
|
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReason("provider_id is required")))
|
|
return
|
|
}
|
|
if len(body.ProviderID) > maxProviderIDLength {
|
|
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReasonf("provider_id must be at most %d characters", maxProviderIDLength)))
|
|
return
|
|
}
|
|
|
|
f, err := NewFlow(h.d, r, flow.TypeBrowser)
|
|
if err != nil {
|
|
h.d.Writer().WriteError(w, r, err)
|
|
return
|
|
}
|
|
// Admin-created flows have no browser cookie to bind against; mint a
|
|
// random UUID as the opaque CSRF bearer and validate it on submit.
|
|
f.CSRFToken = x.NewUUID().String()
|
|
|
|
if err := f.SetTestContext(&TestContext{ProviderID: body.ProviderID}); err != nil {
|
|
h.d.Writer().WriteError(w, r, err)
|
|
return
|
|
}
|
|
// Mark as "refresh" so the OIDC strategy's alreadyAuthenticated guard does
|
|
// not short-circuit the submit if the admin's browser happens to carry an
|
|
// unrelated Ory session cookie. Test flows always run the OIDC round-trip
|
|
// regardless of session state.
|
|
f.Refresh = true
|
|
|
|
strategy := h.d.TestStrategy(r.Context())
|
|
if strategy == nil {
|
|
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrInternalServerError().WithReason("OIDC strategy is not registered; test login flows cannot be created")))
|
|
return
|
|
}
|
|
if err := strategy.PopulateTestLoginFlow(r, f, body.ProviderID); err != nil {
|
|
h.d.Writer().WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
if err := h.d.LoginFlowPersister().CreateLoginFlow(r.Context(), f); err != nil {
|
|
h.d.Writer().WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
h.d.Writer().WriteCode(w, r, http.StatusCreated, f)
|
|
}
|
|
|
|
// Delete Test Login Flow Parameters
|
|
//
|
|
// swagger:parameters deleteTestLoginFlow
|
|
//
|
|
//nolint:deadcode,unused
|
|
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
|
type deleteTestLoginFlow struct {
|
|
// ID of the test login flow to delete.
|
|
//
|
|
// required: true
|
|
// in: query
|
|
ID string `json:"id"`
|
|
|
|
// HTTP Cookies. A captured test flow requires the ory_kratos_test_flow
|
|
// cookie set by the OIDC callback; a flow still in the initial
|
|
// choose-method state does not.
|
|
//
|
|
// in: header
|
|
// name: Cookie
|
|
Cookies string `json:"Cookie"`
|
|
}
|
|
|
|
// swagger:route DELETE /self-service/login/test frontend deleteTestLoginFlow
|
|
//
|
|
// # Delete a test OIDC login flow
|
|
//
|
|
// Deletes a dry-run OIDC test login flow. A flow whose debug payload has
|
|
// been captured requires the HMAC cookie set by the OIDC callback; a flow
|
|
// still in the initial choose-method state is deletable with just the flow
|
|
// ID (it carries no PII, and the admin may want to abandon it).
|
|
//
|
|
// Schemes: http, https
|
|
//
|
|
// Responses:
|
|
// 204: emptyResponse
|
|
// 400: errorGeneric
|
|
// 403: errorGeneric
|
|
// 404: errorGeneric
|
|
// default: errorGeneric
|
|
//
|
|
// Extensions:
|
|
// x-ory-ratelimit-bucket: kratos-public-high
|
|
func (h *Handler) deleteTestLoginFlow(w http.ResponseWriter, r *http.Request) {
|
|
id, err := uuid.FromString(r.URL.Query().Get("id"))
|
|
if err != nil {
|
|
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrBadRequest().WithReason("invalid flow id")))
|
|
return
|
|
}
|
|
|
|
// Load the flow and discriminate by whether a debug payload has been
|
|
// captured. A return of sqlcon.ErrNoRows maps to 404 — we don't leak
|
|
// whether the ID belongs to a non-test login flow vs. a missing row.
|
|
f, err := h.d.LoginFlowPersister().GetLoginFlow(r.Context(), id)
|
|
if err != nil {
|
|
h.d.Writer().WriteError(w, r, err)
|
|
return
|
|
}
|
|
if !f.IsTest() {
|
|
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrNotFound()))
|
|
return
|
|
}
|
|
if err := f.LoadTestContext(); err != nil {
|
|
h.d.Writer().WriteError(w, r, err)
|
|
return
|
|
}
|
|
if f.TestContext != nil && f.TestContext.DebugPayload != nil &&
|
|
!nosurf.VerifyToken(h.d.GenerateCSRFToken(r), f.CSRFToken) {
|
|
h.d.Writer().WriteError(w, r, errors.WithStack(herodot.ErrForbidden().WithReason("missing or invalid CSRF token")))
|
|
return
|
|
}
|
|
|
|
if err := h.d.LoginFlowPersister().DeleteTestLoginFlow(r.Context(), id); err != nil {
|
|
h.d.Writer().WriteError(w, r, err)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|