snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
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
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
This commit is contained in:
38
x/clean_url_test.go
Normal file
38
x/clean_url_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCleanPath(t *testing.T) {
|
||||
r := http.NewServeMux()
|
||||
r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(r.URL.String()))
|
||||
})
|
||||
ts := httptest.NewServer(r)
|
||||
defer ts.Close()
|
||||
|
||||
for k, tc := range [][]string{
|
||||
{"//foo", "/foo"},
|
||||
{"//foo//bar", "/foo/bar"},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
|
||||
res, err := ts.Client().Get(ts.URL + tc[0])
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, string(body), tc[1])
|
||||
})
|
||||
}
|
||||
}
|
||||
95
x/cookie.go
Normal file
95
x/cookie.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// SessionPersistValues adds values to the session store and persists the changes.
|
||||
func SessionPersistValues(w http.ResponseWriter, r *http.Request, s sessions.StoreExact, id string, values map[string]interface{}) error {
|
||||
// The error does not matter because in the worst case we're re-writing the session cookie.
|
||||
cookie, _ := s.Get(r, id)
|
||||
for k, v := range values {
|
||||
cookie.Values[k] = v
|
||||
}
|
||||
|
||||
return errors.WithStack(cookie.Save(r, w))
|
||||
}
|
||||
|
||||
// SessionGetString returns a string for the given id and key or an error if the session is invalid,
|
||||
// the key does not exist, or the key value is not a string.
|
||||
func SessionGetString(r *http.Request, s sessions.StoreExact, id string, key interface{}) (string, error) {
|
||||
check := func(v map[interface{}]interface{}) (string, error) {
|
||||
vv, ok := v[key]
|
||||
if !ok {
|
||||
return "", errors.Errorf("key %s does not exist in cookie: %+v", key, id)
|
||||
} else if vvv, ok := vv.(string); !ok {
|
||||
return "", errors.Errorf("value of key %s is not of type string in cookie", key)
|
||||
} else {
|
||||
return vvv, nil
|
||||
}
|
||||
}
|
||||
|
||||
var exactErr error
|
||||
cookie, err := s.GetExact(r, id, func(s *sessions.Session) bool {
|
||||
_, exactErr = check(s.Values)
|
||||
return exactErr == nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
} else if exactErr != nil {
|
||||
return "", exactErr
|
||||
}
|
||||
|
||||
return check(cookie.Values)
|
||||
}
|
||||
|
||||
// SessionGetStringOr returns a string for the given id and key or the fallback value if the session is invalid,
|
||||
// the key does not exist, or the key value is not a string.
|
||||
func SessionGetStringOr(r *http.Request, s sessions.StoreExact, id, key, fallback string) string {
|
||||
v, err := SessionGetString(r, s, id, key)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func SessionUnset(w http.ResponseWriter, r *http.Request, s sessions.StoreExact, id string) error {
|
||||
cookie, err := s.Get(r, id)
|
||||
if err == nil && cookie.IsNew {
|
||||
// No cookie was sent in the request. We have nothing to do.
|
||||
return nil
|
||||
}
|
||||
|
||||
cookie.Options.MaxAge = -1
|
||||
cookie.Values = make(map[interface{}]interface{})
|
||||
return errors.WithStack(cookie.Save(r, w))
|
||||
}
|
||||
|
||||
func SessionSetExpiresIn(w http.ResponseWriter, r *http.Request, s sessions.StoreExact, id string, expiresIn time.Duration) error {
|
||||
cookie, err := s.Get(r, id)
|
||||
if err == nil && cookie.IsNew {
|
||||
// No cookie was sent in the request. We have nothing to do.
|
||||
return nil
|
||||
}
|
||||
|
||||
cookie.Options.MaxAge = int(expiresIn.Seconds())
|
||||
return errors.WithStack(cookie.Save(r, w))
|
||||
}
|
||||
|
||||
func SessionUnsetKey(w http.ResponseWriter, r *http.Request, s sessions.StoreExact, id, key string) error {
|
||||
cookie, err := s.Get(r, id)
|
||||
if err == nil && cookie.IsNew {
|
||||
// No cookie was sent in the request. We have nothing to do.
|
||||
return nil
|
||||
}
|
||||
|
||||
delete(cookie.Values, key)
|
||||
return errors.WithStack(cookie.Save(r, w))
|
||||
}
|
||||
280
x/cookie_test.go
Normal file
280
x/cookie_test.go
Normal file
@@ -0,0 +1,280 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSession(t *testing.T) {
|
||||
const sid = "test_session"
|
||||
|
||||
s := sessions.NewCookieStore([]byte("cyan cat walking over keyboard"))
|
||||
s.Options.MaxAge = 78652871
|
||||
cj, err := cookiejar.New(&cookiejar.Options{})
|
||||
require.NoError(t, err)
|
||||
// A dedicated transport so that a parallel test closing its httptest server
|
||||
// (which calls http.DefaultTransport.CloseIdleConnections) cannot break our
|
||||
// in-flight requests.
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
t.Cleanup(transport.CloseIdleConnections)
|
||||
c := http.Client{Jar: cj, Transport: transport}
|
||||
plainClient := &http.Client{Transport: transport}
|
||||
|
||||
isExpiryCorrect := func(t *testing.T, r *http.Request) {
|
||||
cookie, _ := s.Get(r, sid)
|
||||
assert.EqualValues(t, 78652871, cookie.Options.MaxAge, "we ensure the options are always copied correctly.")
|
||||
}
|
||||
|
||||
router := http.NewServeMux()
|
||||
router.HandleFunc("GET /set", func(w http.ResponseWriter, r *http.Request) {
|
||||
require.NoError(t, SessionPersistValues(w, r, s, sid, map[string]interface{}{
|
||||
"string-1": "foo",
|
||||
"string-2": "bar",
|
||||
"string-3": "",
|
||||
"int": 1234,
|
||||
}))
|
||||
isExpiryCorrect(t, r)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(router)
|
||||
defer ts.Close()
|
||||
|
||||
mr := func(t *testing.T, path string) {
|
||||
res, err := c.Get(ts.URL + "/" + path)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, http.StatusNoContent, res.StatusCode)
|
||||
require.NoError(t, res.Body.Close())
|
||||
}
|
||||
mr(t, "set")
|
||||
|
||||
t.Run("case=GetString", func(t *testing.T) {
|
||||
id := "get-string"
|
||||
router.HandleFunc("GET /"+id, func(w http.ResponseWriter, r *http.Request) {
|
||||
got, err := SessionGetString(r, s, sid, "string-1")
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, "foo", got)
|
||||
|
||||
got, err = SessionGetString(r, s, sid, "string-2")
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, "bar", got)
|
||||
|
||||
got, err = SessionGetString(r, s, sid, "string-3")
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, "", got)
|
||||
|
||||
_, err = SessionGetString(r, s, sid, "int")
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = SessionGetString(r, s, sid, "i-dont-exist")
|
||||
require.Error(t, err)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=GetStringMultipleCookies", func(t *testing.T) {
|
||||
id := "get-string-multiple"
|
||||
|
||||
router.HandleFunc("GET /set/"+id, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.NoError(t, SessionPersistValues(w, r, s, sid, map[string]interface{}{
|
||||
"multiple-string-1": "foo",
|
||||
}))
|
||||
require.NoError(t, SessionPersistValues(w, r, s, sid, map[string]interface{}{
|
||||
"multiple-string-2": "bar",
|
||||
}))
|
||||
isExpiryCorrect(t, r)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
router.HandleFunc("GET /get/"+id, func(w http.ResponseWriter, r *http.Request) {
|
||||
got, err := SessionGetString(r, s, sid, "multiple-string-1")
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, "foo", got)
|
||||
|
||||
got, err = SessionGetString(r, s, sid, "multiple-string-2")
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, "bar", got)
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
res, err := plainClient.Get(ts.URL + "/set/" + id)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, http.StatusNoContent, res.StatusCode)
|
||||
require.NoError(t, res.Body.Close())
|
||||
|
||||
req, _ := http.NewRequest("GET", ts.URL+"/get/"+id, nil)
|
||||
for _, c := range res.Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
res, err = plainClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, http.StatusNoContent, res.StatusCode)
|
||||
require.NoError(t, res.Body.Close())
|
||||
})
|
||||
|
||||
t.Run("case=GetStringOr", func(t *testing.T) {
|
||||
id := "get-string-or"
|
||||
router.HandleFunc("GET /"+id, func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.EqualValues(t, "foo", SessionGetStringOr(r, s, sid, "string-1", "baz"))
|
||||
assert.EqualValues(t, "bar", SessionGetStringOr(r, s, sid, "string-2", "baz"))
|
||||
assert.EqualValues(t, "", SessionGetStringOr(r, s, sid, "string-3", "baz"))
|
||||
assert.EqualValues(t, "baz", SessionGetStringOr(r, s, sid, "int", "baz"))
|
||||
assert.EqualValues(t, "baz", SessionGetStringOr(r, s, sid, "i-dont-exist", "baz"))
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
mr(t, id)
|
||||
})
|
||||
|
||||
mrCookie := func(t *testing.T, loc string, cks []*http.Cookie, f func(c []*http.Cookie, req *http.Request)) *http.Response {
|
||||
req, err := http.NewRequest("GET", loc, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Mess with the signature
|
||||
if f != nil {
|
||||
f(cks, req)
|
||||
}
|
||||
|
||||
res, err := plainClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
return res
|
||||
}
|
||||
|
||||
mrUnset := func(t *testing.T, ts *httptest.Server, id string, f func(c []*http.Cookie, req *http.Request)) *http.Response {
|
||||
res, err := plainClient.Get(ts.URL + "/set")
|
||||
require.NoError(t, err)
|
||||
|
||||
res = mrCookie(t, ts.URL+"/"+id+"/unset", res.Cookies(), f)
|
||||
require.EqualValues(t, http.StatusNoContent, res.StatusCode)
|
||||
require.NoError(t, res.Body.Close())
|
||||
return res
|
||||
}
|
||||
|
||||
signatureScrambler := func(c []*http.Cookie, req *http.Request) {
|
||||
for _, c := range c {
|
||||
c.Value = strings.ReplaceAll(c.Value, "a", "b")
|
||||
req.AddCookie(c)
|
||||
}
|
||||
}
|
||||
|
||||
cookieCopier := func(c []*http.Cookie, req *http.Request) {
|
||||
for _, c := range c {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("case=SessionSet", func(t *testing.T) {
|
||||
res, err := plainClient.Get(ts.URL + "/set")
|
||||
require.NoError(t, err)
|
||||
|
||||
res = mrCookie(t, ts.URL+"/set", res.Cookies(), signatureScrambler)
|
||||
require.EqualValues(t, http.StatusNoContent, res.StatusCode)
|
||||
require.NoError(t, res.Body.Close())
|
||||
})
|
||||
|
||||
t.Run("case=SessionUnset", func(t *testing.T) {
|
||||
t.Run("prevent https://github.com/gorilla/sessions/pull/251", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
require.NoError(t, SessionUnset(w, new(http.Request), s, "idonotexist"))
|
||||
require.NoError(t, SessionUnset(w, new(http.Request), s, "idonotexist"))
|
||||
require.Len(t, w.Result().Cookies(), 0)
|
||||
})
|
||||
|
||||
id := "session-unset"
|
||||
router.HandleFunc("GET /"+id+"/unset", func(w http.ResponseWriter, r *http.Request) {
|
||||
require.NoError(t, SessionUnset(w, r, s, sid))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
cookie, _ := s.Get(r, sid)
|
||||
assert.EqualValues(t, -1, cookie.Options.MaxAge, "we ensure the options are always copied correctly.")
|
||||
})
|
||||
|
||||
router.HandleFunc("GET /"+id+"/get", func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Empty(t, SessionGetStringOr(r, s, sid, "string-1", ""))
|
||||
require.Empty(t, SessionGetStringOr(r, s, sid, "string-2", ""))
|
||||
require.Empty(t, SessionGetStringOr(r, s, sid, "string-3", ""))
|
||||
require.Empty(t, SessionGetStringOr(r, s, sid, "int", ""))
|
||||
require.Empty(t, SessionGetStringOr(r, s, sid, "i-dont-exist", ""))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
t.Run("with invalid cookie signature", func(t *testing.T) {
|
||||
res := mrUnset(t, ts, id, signatureScrambler)
|
||||
assert.Len(t, res.Cookies(), 1)
|
||||
mrCookie(t, ts.URL+"/"+id+"/get", res.Cookies(), cookieCopier)
|
||||
require.EqualValues(t, http.StatusNoContent, res.StatusCode)
|
||||
require.NoError(t, res.Body.Close())
|
||||
})
|
||||
|
||||
t.Run("with valid cookie signature", func(t *testing.T) {
|
||||
res := mrUnset(t, ts, id, cookieCopier)
|
||||
assert.Len(t, res.Cookies(), 1)
|
||||
mrCookie(t, ts.URL+"/"+id+"/get", res.Cookies(), cookieCopier)
|
||||
require.EqualValues(t, http.StatusNoContent, res.StatusCode)
|
||||
require.NoError(t, res.Body.Close())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=SessionUnsetKey", func(t *testing.T) {
|
||||
t.Run("prevent https://github.com/gorilla/sessions/pull/251", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
require.NoError(t, SessionUnsetKey(w, new(http.Request), s, "idonotexist", ""))
|
||||
require.NoError(t, SessionUnsetKey(w, new(http.Request), s, "idonotexist", ""))
|
||||
require.Len(t, w.Result().Cookies(), 0)
|
||||
})
|
||||
|
||||
id := "session-unset-key"
|
||||
router.HandleFunc("GET /"+id+"/unset", func(w http.ResponseWriter, r *http.Request) {
|
||||
require.NoError(t, SessionUnsetKey(w, r, s, sid, "string-1"))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
isExpiryCorrect(t, r)
|
||||
})
|
||||
|
||||
router.HandleFunc("GET /"+id+"/expect-unset", func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Empty(t, SessionGetStringOr(r, s, sid, "string-1", ""))
|
||||
require.Empty(t, SessionGetStringOr(r, s, sid, "string-2", ""))
|
||||
require.Empty(t, SessionGetStringOr(r, s, sid, "string-3", ""))
|
||||
require.Empty(t, SessionGetStringOr(r, s, sid, "int", ""))
|
||||
require.Empty(t, SessionGetStringOr(r, s, sid, "i-dont-exist", ""))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
router.HandleFunc("GET /"+id+"/expect-one", func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Empty(t, SessionGetStringOr(r, s, sid, "string-1", ""))
|
||||
assert.EqualValues(t, "bar", SessionGetStringOr(r, s, sid, "string-2", "baz"))
|
||||
assert.EqualValues(t, "", SessionGetStringOr(r, s, sid, "string-3", "baz"))
|
||||
assert.EqualValues(t, "baz", SessionGetStringOr(r, s, sid, "int", "baz"))
|
||||
assert.EqualValues(t, "baz", SessionGetStringOr(r, s, sid, "i-dont-exist", "baz"))
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
t.Run("with invalid cookie signature", func(t *testing.T) {
|
||||
res := mrUnset(t, ts, id, signatureScrambler)
|
||||
assert.Len(t, res.Cookies(), 1)
|
||||
mrCookie(t, ts.URL+"/"+id+"/expect-unset", res.Cookies(), cookieCopier)
|
||||
require.EqualValues(t, http.StatusNoContent, res.StatusCode)
|
||||
require.NoError(t, res.Body.Close())
|
||||
})
|
||||
|
||||
t.Run("with valid cookie signature", func(t *testing.T) {
|
||||
res := mrUnset(t, ts, id, cookieCopier)
|
||||
assert.Len(t, res.Cookies(), 1)
|
||||
mrCookie(t, ts.URL+"/"+id+"/expect-one", res.Cookies(), cookieCopier)
|
||||
require.EqualValues(t, http.StatusNoContent, res.StatusCode)
|
||||
require.NoError(t, res.Body.Close())
|
||||
})
|
||||
})
|
||||
}
|
||||
11
x/doc.go
Normal file
11
x/doc.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/*
|
||||
Package x provides various helpers that do not have an obvious home elsewhere.
|
||||
|
||||
The contract implied here, is that:
|
||||
- Package x does not depend on other parts of kratos
|
||||
- Packages outside kratos do not depend on x.
|
||||
*/
|
||||
package x
|
||||
9
x/edition_oss.go
Normal file
9
x/edition_oss.go
Normal file
@@ -0,0 +1,9 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//go:build !commercial
|
||||
|
||||
package x
|
||||
|
||||
// Edition reports which build edition this binary was compiled as.
|
||||
func Edition() string { return "oss" }
|
||||
18
x/edition_oss_test.go
Normal file
18
x/edition_oss_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//go:build !commercial
|
||||
|
||||
package x_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/ory/kratos/x"
|
||||
)
|
||||
|
||||
func TestEdition(t *testing.T) {
|
||||
assert.Equal(t, "oss", x.Edition())
|
||||
}
|
||||
69
x/email.go
Normal file
69
x/email.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/net/idna"
|
||||
|
||||
"github.com/ory/jsonschema/v3"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// email_loose accepts non-RFC carrier addresses (e.g. "foo.@docomo.ne.jp")
|
||||
// that the strict RFC 5322 "email" format rejects, while still rejecting
|
||||
// values that are unsafe in an SMTP envelope or an email header.
|
||||
jsonschema.Formats["email_loose"] = func(v interface{}) bool {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
// Non-string values are handled by the "type" keyword.
|
||||
return true
|
||||
}
|
||||
return IsEmailAddress(s)
|
||||
}
|
||||
}
|
||||
|
||||
// emailLocalAtext is the RFC 5322 "atext" punctuation permitted in the local
|
||||
// part. It deliberately excludes the RFC "specials" — "()<>[]:;@\,\"" — and all
|
||||
// whitespace/control characters, which could break out of an SMTP envelope or
|
||||
// inject an email header.
|
||||
const emailLocalAtext = "!#$%&'*+/=?^_`{|}~.-"
|
||||
|
||||
// maxEmailAddressLength caps accepted addresses. 320 is the historical RFC 3696
|
||||
// bound (64-octet local part + "@" + 255-octet domain).
|
||||
const maxEmailAddressLength = 320
|
||||
|
||||
// IsEmailAddress reports whether s is shaped like an email address using a
|
||||
// lenient, injection-safe check rather than a strict RFC 5322 parse: a local
|
||||
// part of letters, digits, and atext punctuation (dot placement is not
|
||||
// restricted, so carrier addresses like "foo.@docomo.ne.jp" pass), a single
|
||||
// "@", and a domain that is a valid host name per idna.Lookup. It rejects
|
||||
// control characters, whitespace, the RFC "specials" that enable SMTP-envelope
|
||||
// or header injection, and addresses longer than 320 bytes. It is structural
|
||||
// only and does not verify that the domain resolves or is deliverable.
|
||||
func IsEmailAddress(s string) bool {
|
||||
if len(s) == 0 || len(s) > maxEmailAddressLength {
|
||||
return false
|
||||
}
|
||||
local, domain, ok := strings.Cut(s, "@")
|
||||
if !ok || local == "" || domain == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range local {
|
||||
switch {
|
||||
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
||||
case strings.ContainsRune(emailLocalAtext, r):
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
// idna.Lookup rejects any domain that is not a valid host name, so it also
|
||||
// gates out embedded "@", control characters, whitespace, and specials.
|
||||
if _, err := idna.Lookup.ToASCII(domain); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
62
x/email_fuzz_test.go
Normal file
62
x/email_fuzz_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
// FuzzIsEmailAddress asserts the safety invariants of every string that
|
||||
// IsEmailAddress accepts. These invariants — not the example table — are the
|
||||
// actual injection-safety contract relied on by the courier SMTP path.
|
||||
func FuzzIsEmailAddress(f *testing.F) {
|
||||
for _, seed := range []string{
|
||||
"foo@ory.sh",
|
||||
"foo.@docomo.ne.jp",
|
||||
".foo@docomo.ne.jp",
|
||||
"foo..bar@docomo.ne.jp",
|
||||
"a!#$%&'*+/=?^_`{|}~-@ory.sh",
|
||||
"föö@münchen.de",
|
||||
"foo@ory.sh\r\nBcc: evil@x",
|
||||
"foo<bar>@ory.sh",
|
||||
"foo@bar@ory.sh",
|
||||
"@ory.sh",
|
||||
"foo@",
|
||||
"",
|
||||
} {
|
||||
f.Add(seed)
|
||||
}
|
||||
|
||||
f.Fuzz(func(t *testing.T, s string) {
|
||||
if !IsEmailAddress(s) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(s) > maxEmailAddressLength {
|
||||
t.Fatalf("accepted over-long address (%d bytes)", len(s))
|
||||
}
|
||||
if strings.Count(s, "@") != 1 {
|
||||
t.Fatalf("accepted address without exactly one @: %q", s)
|
||||
}
|
||||
local, domain, _ := strings.Cut(s, "@")
|
||||
if local == "" || domain == "" {
|
||||
t.Fatalf("accepted empty local part or domain: %q", s)
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < 0x20 || r == 0x7f || unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||
t.Fatalf("accepted control/whitespace rune %U in %q", r, s)
|
||||
}
|
||||
if r != '@' && strings.ContainsRune("()<>[]:;@\\,\"", r) {
|
||||
t.Fatalf("accepted RFC specials rune %q in %q", r, s)
|
||||
}
|
||||
}
|
||||
if _, err := idna.Lookup.ToASCII(domain); err != nil {
|
||||
t.Fatalf("accepted domain that idna rejects (%v) in %q", err, s)
|
||||
}
|
||||
})
|
||||
}
|
||||
67
x/email_test.go
Normal file
67
x/email_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsEmailAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
// Accepted: normal and non-RFC-but-safe carrier addresses.
|
||||
{name: "plain", in: "foo@ory.sh", want: true},
|
||||
{name: "subdomain and plus", in: "foo.bar+tag@mail.ory.sh", want: true},
|
||||
{name: "docomo trailing dot", in: "foo.@docomo.ne.jp", want: true},
|
||||
{name: "docomo double dot", in: "foo..bar@docomo.ne.jp", want: true},
|
||||
{name: "docomo leading dot", in: ".foo@docomo.ne.jp", want: true},
|
||||
{name: "internationalized local part", in: "föö@ory.sh", want: true},
|
||||
{name: "atext punctuation", in: "a!#$%&'*+/=?^_`{|}~-@ory.sh", want: true},
|
||||
{name: "single label domain", in: "foo@localhost", want: true},
|
||||
{name: "idn domain accepted via idna", in: "foo@münchen.de", want: true},
|
||||
{name: "exactly 320 bytes", in: strings.Repeat("a", 313) + "@ory.sh", want: true},
|
||||
{name: "321 bytes over cap", in: strings.Repeat("a", 314) + "@ory.sh", want: false},
|
||||
|
||||
// Rejected: not an address.
|
||||
{name: "empty", in: "", want: false},
|
||||
{name: "no at", in: "invalid-email", want: false},
|
||||
{name: "empty local", in: "@ory.sh", want: false},
|
||||
{name: "empty domain", in: "foo@", want: false},
|
||||
{name: "two at signs", in: "foo@bar@ory.sh", want: false},
|
||||
|
||||
// Rejected: injection / SMTP-envelope specials and whitespace.
|
||||
{name: "CR injection", in: "foo@ory.sh\r\nBcc: evil@x", want: false},
|
||||
{name: "LF injection", in: "foo@ory.sh\nBcc: evil@x", want: false},
|
||||
{name: "space", in: "foo bar@ory.sh", want: false},
|
||||
{name: "tab", in: "foo\t@ory.sh", want: false},
|
||||
{name: "angle brackets", in: "foo<bar>@ory.sh", want: false},
|
||||
{name: "comma", in: "foo@ory.sh,evil@x", want: false},
|
||||
{name: "semicolon", in: "foo;bar@ory.sh", want: false},
|
||||
{name: "colon", in: "foo:bar@ory.sh", want: false},
|
||||
{name: "double quote", in: "\"foo\"@ory.sh", want: false},
|
||||
{name: "backslash", in: "foo\\bar@ory.sh", want: false},
|
||||
{name: "parenthesis", in: "foo(comment)@ory.sh", want: false},
|
||||
{name: "square bracket ip literal", in: "foo@[192.168.0.1]", want: false},
|
||||
|
||||
// Rejected: domain is not a valid host name (idna Lookup profile).
|
||||
{name: "bang in domain", in: "foo@bar!baz", want: false},
|
||||
{name: "percent in domain", in: "foo@bar%baz", want: false},
|
||||
{name: "underscore in domain", in: "foo@bar_baz.com", want: false},
|
||||
{name: "backtick in domain", in: "foo@ba`r.com", want: false},
|
||||
{name: "over length cap", in: strings.Repeat("a", 310) + "@" + strings.Repeat("b", 20) + ".jp", want: false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert.Equal(t, tc.want, IsEmailAddress(tc.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
66
x/err.go
Normal file
66
x/err.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
)
|
||||
|
||||
type WithIdentityIDError struct {
|
||||
err error
|
||||
identityID uuid.UUID
|
||||
}
|
||||
|
||||
func (e *WithIdentityIDError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e *WithIdentityIDError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
func (e *WithIdentityIDError) IdentityID() uuid.UUID {
|
||||
return e.identityID
|
||||
}
|
||||
|
||||
func WrapWithIdentityIDError(err error, identityID uuid.UUID) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &WithIdentityIDError{
|
||||
err: err,
|
||||
identityID: identityID,
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
PseudoPanic = herodot.DefaultError{
|
||||
StatusField: http.StatusText(http.StatusInternalServerError),
|
||||
ErrorField: "Code Bug Detected",
|
||||
ReasonField: "The code ended up at a place where it should not have. Please report this as an issue at https://github.com/ory/kratos",
|
||||
CodeField: http.StatusInternalServerError,
|
||||
}
|
||||
PageTokenInvalid = herodot.ErrBadRequest().WithReason("The page token is invalid, do not craft your own page tokens")
|
||||
)
|
||||
|
||||
func RecoverStatusCode(err error, fallback int) int {
|
||||
var sc herodot.StatusCodeCarrier
|
||||
if errors.As(err, &sc) {
|
||||
return sc.StatusCode()
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func Must[T any](t T, err error) T {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return t
|
||||
}
|
||||
83
x/err_test.go
Normal file
83
x/err_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copyright © 2025 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/kratos/x"
|
||||
)
|
||||
|
||||
func TestWrapWithIdentityIDError(t *testing.T) {
|
||||
t.Run("case=wraps error with identity ID", func(t *testing.T) {
|
||||
baseErr := errors.New("test error")
|
||||
identityID := uuid.Must(uuid.NewV4())
|
||||
|
||||
wrappedErr := x.WrapWithIdentityIDError(baseErr, identityID)
|
||||
|
||||
require.NotNil(t, wrappedErr)
|
||||
assert.Equal(t, "test error", wrappedErr.Error())
|
||||
|
||||
var withIDErr *x.WithIdentityIDError
|
||||
require.True(t, errors.As(wrappedErr, &withIDErr))
|
||||
assert.Equal(t, identityID, withIDErr.IdentityID())
|
||||
})
|
||||
|
||||
t.Run("case=unwraps to original error", func(t *testing.T) {
|
||||
baseErr := errors.New("original error")
|
||||
identityID := uuid.Must(uuid.NewV4())
|
||||
|
||||
wrappedErr := x.WrapWithIdentityIDError(baseErr, identityID)
|
||||
|
||||
unwrappedErr := errors.Unwrap(wrappedErr)
|
||||
assert.Equal(t, baseErr, unwrappedErr)
|
||||
})
|
||||
|
||||
t.Run("case=returns nil when wrapping nil error", func(t *testing.T) {
|
||||
identityID := uuid.Must(uuid.NewV4())
|
||||
|
||||
wrappedErr := x.WrapWithIdentityIDError(nil, identityID)
|
||||
|
||||
assert.Nil(t, wrappedErr)
|
||||
})
|
||||
|
||||
t.Run("case=preserves identity ID with nil UUID", func(t *testing.T) {
|
||||
baseErr := errors.New("test error")
|
||||
var identityID uuid.UUID // nil UUID
|
||||
|
||||
wrappedErr := x.WrapWithIdentityIDError(baseErr, identityID)
|
||||
|
||||
var withIDErr *x.WithIdentityIDError
|
||||
require.True(t, errors.As(wrappedErr, &withIDErr))
|
||||
assert.Equal(t, uuid.Nil, withIDErr.IdentityID())
|
||||
})
|
||||
|
||||
t.Run("case=can wrap already wrapped error", func(t *testing.T) {
|
||||
baseErr := errors.New("base error")
|
||||
firstID := uuid.Must(uuid.NewV4())
|
||||
secondID := uuid.Must(uuid.NewV4())
|
||||
|
||||
firstWrap := x.WrapWithIdentityIDError(baseErr, firstID)
|
||||
secondWrap := x.WrapWithIdentityIDError(firstWrap, secondID)
|
||||
|
||||
var withIDErr *x.WithIdentityIDError
|
||||
require.True(t, errors.As(secondWrap, &withIDErr))
|
||||
// Should get the outermost identity ID
|
||||
assert.Equal(t, secondID, withIDErr.IdentityID())
|
||||
})
|
||||
|
||||
t.Run("case=works with errors.Is", func(t *testing.T) {
|
||||
baseErr := errors.New("base error")
|
||||
identityID := uuid.Must(uuid.NewV4())
|
||||
|
||||
wrappedErr := x.WrapWithIdentityIDError(baseErr, identityID)
|
||||
|
||||
assert.True(t, errors.Is(wrappedErr, baseErr))
|
||||
})
|
||||
}
|
||||
596
x/events/events.go
Normal file
596
x/events/events.go
Normal file
@@ -0,0 +1,596 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package events
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
otelattr "go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/kratos/schema"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/x/jsonx"
|
||||
"github.com/ory/x/otelx/semconv"
|
||||
)
|
||||
|
||||
const (
|
||||
IdentityCreated semconv.Event = "IdentityCreated"
|
||||
IdentityDeleted semconv.Event = "IdentityDeleted"
|
||||
IdentityUpdated semconv.Event = "IdentityUpdated"
|
||||
JsonnetMappingFailed semconv.Event = "JsonnetMappingFailed"
|
||||
LoginFailed semconv.Event = "LoginFailed"
|
||||
LoginInitiated semconv.Event = "LoginInitiated"
|
||||
LoginSucceeded semconv.Event = "LoginSucceeded"
|
||||
RecoveryFailed semconv.Event = "RecoveryFailed"
|
||||
RecoveryInitiatedByAdmin semconv.Event = "RecoveryInitiatedByAdmin"
|
||||
RecoverySucceeded semconv.Event = "RecoverySucceeded"
|
||||
RegistrationFailed semconv.Event = "RegistrationFailed"
|
||||
RegistrationInitiated semconv.Event = "RegistrationInitiated"
|
||||
RegistrationSucceeded semconv.Event = "RegistrationSucceeded"
|
||||
SessionChanged semconv.Event = "SessionChanged"
|
||||
SessionChecked semconv.Event = "SessionChecked"
|
||||
SessionIssued semconv.Event = "SessionIssued"
|
||||
SessionLifespanExtended semconv.Event = "SessionLifespanExtended"
|
||||
SessionRevoked semconv.Event = "SessionRevoked"
|
||||
SessionTokenizedAsJWT semconv.Event = "SessionTokenizedAsJWT"
|
||||
SettingsFailed semconv.Event = "SettingsFailed"
|
||||
SettingsSucceeded semconv.Event = "SettingsSucceeded"
|
||||
VerificationFailed semconv.Event = "VerificationFailed"
|
||||
VerificationSucceeded semconv.Event = "VerificationSucceeded"
|
||||
WebhookDelivered semconv.Event = "WebhookDelivered"
|
||||
WebhookFailed semconv.Event = "WebhookFailed"
|
||||
WebhookSucceeded semconv.Event = "WebhookSucceeded"
|
||||
CourierMessageAbandoned semconv.Event = "CourierMessageAbandoned"
|
||||
CourierMessageDispatched semconv.Event = "CourierMessageDispatched"
|
||||
)
|
||||
|
||||
const (
|
||||
AttributeKeyErrorReason semconv.AttributeKey = "ErrorReason"
|
||||
AttributeKeyFlowID semconv.AttributeKey = "FlowID"
|
||||
AttributeKeyFlowRefresh semconv.AttributeKey = "FlowRefresh"
|
||||
AttributeKeyFlowRequestedAAL semconv.AttributeKey = "FlowRequestedAAL"
|
||||
AttributeKeyJsonnetInput semconv.AttributeKey = "JsonnetInput"
|
||||
AttributeKeyJsonnetOutput semconv.AttributeKey = "JsonnetOutput"
|
||||
AttributeKeyLoginRequestedAAL semconv.AttributeKey = "LoginRequestedAAL"
|
||||
AttributeKeyLoginRequestedPrivilegedSession semconv.AttributeKey = "LoginRequestedPrivilegedSession"
|
||||
AttributeKeyOrganizationID semconv.AttributeKey = "OrganizationID"
|
||||
AttributeKeyReason semconv.AttributeKey = "Reason" // Deprecated, use AttributeKeyErrorReason
|
||||
// AttributeKeySelfServiceFlowType is the type of self-service flow, e.g. "api" or "browser".
|
||||
AttributeKeySelfServiceFlowType semconv.AttributeKey = "SelfServiceFlowType"
|
||||
// AttributeKeySelfServiceFlowName is the client used in the self-service flow, e.g. "login" or "registration"
|
||||
AttributeKeySelfServiceFlowName semconv.AttributeKey = "SelfServiceFlowName"
|
||||
// AttributeKeySelfServiceMethodUsed is the strategy used in the self-service flow, e.g. "oidc" or "password".
|
||||
AttributeKeySelfServiceMethodUsed semconv.AttributeKey = "SelfServiceMethodUsed"
|
||||
AttributeKeySelfServiceSSOProviderUsed semconv.AttributeKey = "SelfServiceSSOProviderUsed"
|
||||
// AttributeKeySelfServiceStrategyUsed is the name of the Flow used in the self-service flow, e.g. "login" or "registration".
|
||||
//
|
||||
// Deprecated: use AttributeKeySelfServiceFlowName instead.
|
||||
AttributeKeySelfServiceStrategyUsed semconv.AttributeKey = "SelfServiceStrategyUsed"
|
||||
AttributeKeySessionAAL semconv.AttributeKey = "SessionAAL"
|
||||
AttributeKeySessionExpiresAt semconv.AttributeKey = "SessionExpiresAt"
|
||||
AttributeKeySessionID semconv.AttributeKey = "SessionID"
|
||||
AttributeKeyTokenizedSessionTTL semconv.AttributeKey = "TokenizedSessionTTL"
|
||||
AttributeKeyWebhookAttemptNumber semconv.AttributeKey = "WebhookAttemptNumber"
|
||||
AttributeKeyWebhookID semconv.AttributeKey = "WebhookID"
|
||||
AttributeKeyWebhookRequestBody semconv.AttributeKey = "WebhookRequestBody"
|
||||
AttributeKeyWebhookRequestID semconv.AttributeKey = "WebhookRequestID"
|
||||
AttributeKeyWebhookResponseBody semconv.AttributeKey = "WebhookResponseBody"
|
||||
AttributeKeyWebhookResponseStatusCode semconv.AttributeKey = "WebhookResponseStatusCode"
|
||||
AttributeKeyWebhookTriggerID semconv.AttributeKey = "WebhookTriggerID"
|
||||
AttributeKeyWebhookURL semconv.AttributeKey = "WebhookURL"
|
||||
AttributeKeyCourierMessageID semconv.AttributeKey = "CourierMessageID"
|
||||
AttributeKeyCourierMessageChannel semconv.AttributeKey = "CourierMessageChannel"
|
||||
AttributeKeyCourierMessageTemplateType semconv.AttributeKey = "CourierMessageTemplateType"
|
||||
)
|
||||
|
||||
func attrSessionID(val uuid.UUID) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeySessionID.String(), val.String())
|
||||
}
|
||||
|
||||
func attrTokenizedSessionTTL(ttl time.Duration) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeyTokenizedSessionTTL.String(), ttl.String())
|
||||
}
|
||||
|
||||
func attrSessionAAL(val string) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeySessionAAL.String(), val)
|
||||
}
|
||||
|
||||
func attLoginRequestedAAL(val string) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeyLoginRequestedAAL.String(), val)
|
||||
}
|
||||
|
||||
func attrFlowRefresh(val bool) otelattr.KeyValue {
|
||||
return otelattr.Bool(AttributeKeyFlowRefresh.String(), val)
|
||||
}
|
||||
|
||||
func attrOrganizationID(val string) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeyOrganizationID.String(), val)
|
||||
}
|
||||
|
||||
func attrFlowRequestedAAL(val string) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeyFlowRequestedAAL.String(), val)
|
||||
}
|
||||
|
||||
func attSessionExpiresAt(expiresAt time.Time) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeySessionExpiresAt.String(), expiresAt.String())
|
||||
}
|
||||
|
||||
func attLoginRequestedPrivilegedSession(val bool) otelattr.KeyValue {
|
||||
return otelattr.Bool(AttributeKeyLoginRequestedPrivilegedSession.String(), val)
|
||||
}
|
||||
|
||||
func attrSelfServiceFlowType(val string) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeySelfServiceFlowType.String(), val)
|
||||
}
|
||||
|
||||
func attrSelfServiceMethodUsed(val string) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeySelfServiceMethodUsed.String(), val)
|
||||
}
|
||||
|
||||
func attrSelfServiceSSOProviderUsed(val string) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeySelfServiceSSOProviderUsed.String(), val)
|
||||
}
|
||||
|
||||
func attrWebhookID(id string) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeyWebhookID.String(), id)
|
||||
}
|
||||
|
||||
func attrWebhookURL(URL *url.URL) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeyWebhookURL.String(), URL.Redacted())
|
||||
}
|
||||
|
||||
func attrWebhookReq(body []byte) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeyWebhookRequestBody.String(), string(body))
|
||||
}
|
||||
|
||||
func attrWebhookRes(body []byte) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeyWebhookResponseBody.String(), string(body))
|
||||
}
|
||||
|
||||
func attrWebhookStatus(status int) otelattr.KeyValue {
|
||||
return otelattr.Int(AttributeKeyWebhookResponseStatusCode.String(), status)
|
||||
}
|
||||
|
||||
func attrWebhookAttempt(n int) otelattr.KeyValue {
|
||||
return otelattr.Int(AttributeKeyWebhookAttemptNumber.String(), n)
|
||||
}
|
||||
|
||||
func attrWebhookRequestID(id uuid.UUID) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeyWebhookRequestID.String(), id.String())
|
||||
}
|
||||
|
||||
func attrWebhookTriggerID(id uuid.UUID) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeyWebhookTriggerID.String(), id.String())
|
||||
}
|
||||
|
||||
// deprecated: use attrErrorReason instead
|
||||
func attrReason(err error) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeyReason.String(), reasonForError(err))
|
||||
}
|
||||
|
||||
func attrErrorReason(err error) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeyErrorReason.String(), reasonForError(err))
|
||||
}
|
||||
|
||||
func attrJsonnetInput(in []byte) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeyJsonnetInput.String(), string(jsonx.Anonymize(in)))
|
||||
}
|
||||
|
||||
func attrJsonnetOutput(out string) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeyJsonnetOutput.String(), string(jsonx.Anonymize([]byte(out))))
|
||||
}
|
||||
|
||||
func attrFlowID(id uuid.UUID) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeyFlowID.String(), id.String())
|
||||
}
|
||||
|
||||
func attrCourierMessageID(id uuid.UUID) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeyCourierMessageID.String(), id.String())
|
||||
}
|
||||
|
||||
func attrCourierMessageChannel(channel string) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeyCourierMessageChannel.String(), channel)
|
||||
}
|
||||
|
||||
func attrCourierMessageTemplateType(templateType string) otelattr.KeyValue {
|
||||
return otelattr.String(AttributeKeyCourierMessageTemplateType.String(), templateType)
|
||||
}
|
||||
|
||||
func NewSessionIssued(ctx context.Context, aal string, sessionID, identityID uuid.UUID) (string, trace.EventOption) {
|
||||
return SessionIssued.String(),
|
||||
trace.WithAttributes(
|
||||
append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
semconv.AttrIdentityID(identityID),
|
||||
attrSessionID(sessionID),
|
||||
attrSessionAAL(aal),
|
||||
)...,
|
||||
)
|
||||
}
|
||||
|
||||
func NewSessionChanged(ctx context.Context, aal string, sessionID, identityID uuid.UUID) (string, trace.EventOption) {
|
||||
return SessionChanged.String(),
|
||||
trace.WithAttributes(
|
||||
append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
semconv.AttrIdentityID(identityID),
|
||||
attrSessionID(sessionID),
|
||||
attrSessionAAL(aal),
|
||||
)...,
|
||||
)
|
||||
}
|
||||
|
||||
func NewSessionLifespanExtended(ctx context.Context, sessionID, identityID uuid.UUID, newExpiry time.Time) (string, trace.EventOption) {
|
||||
return SessionLifespanExtended.String(),
|
||||
trace.WithAttributes(
|
||||
append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
semconv.AttrIdentityID(identityID),
|
||||
attrSessionID(sessionID),
|
||||
attSessionExpiresAt(newExpiry),
|
||||
)...,
|
||||
)
|
||||
}
|
||||
|
||||
type LoginSucceededOpts struct {
|
||||
SessionID, IdentityID, FlowID uuid.UUID
|
||||
FlowType, RequestedAAL, Method, SSOProvider string
|
||||
IsRefresh bool
|
||||
}
|
||||
|
||||
func NewLoginSucceeded(ctx context.Context, o *LoginSucceededOpts) (string, trace.EventOption) {
|
||||
return LoginSucceeded.String(),
|
||||
trace.WithAttributes(
|
||||
append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
semconv.AttrIdentityID(o.IdentityID),
|
||||
attrSessionID(o.SessionID),
|
||||
attrSelfServiceFlowType(o.FlowType),
|
||||
attLoginRequestedAAL(o.RequestedAAL),
|
||||
attLoginRequestedPrivilegedSession(o.IsRefresh),
|
||||
attrSelfServiceMethodUsed(o.Method),
|
||||
attrSelfServiceSSOProviderUsed(o.SSOProvider),
|
||||
attrFlowID(o.FlowID),
|
||||
)...,
|
||||
)
|
||||
}
|
||||
|
||||
func NewRegistrationSucceeded(ctx context.Context, flowID, identityID uuid.UUID, flowType, method, provider string) (string, trace.EventOption) {
|
||||
return RegistrationSucceeded.String(),
|
||||
trace.WithAttributes(append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
attrSelfServiceFlowType(flowType),
|
||||
semconv.AttrIdentityID(identityID),
|
||||
attrSelfServiceMethodUsed(method),
|
||||
attrSelfServiceSSOProviderUsed(provider),
|
||||
attrFlowID(flowID),
|
||||
)...)
|
||||
}
|
||||
|
||||
func NewRecoverySucceeded(ctx context.Context, flowID, identityID uuid.UUID, flowType, method string) (string, trace.EventOption) {
|
||||
return RecoverySucceeded.String(),
|
||||
trace.WithAttributes(append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
attrSelfServiceFlowType(flowType),
|
||||
semconv.AttrIdentityID(identityID),
|
||||
attrSelfServiceMethodUsed(method),
|
||||
attrFlowID(flowID),
|
||||
)...)
|
||||
}
|
||||
|
||||
func NewRecoveryInitiatedByAdmin(ctx context.Context, flowID, identityID uuid.UUID, flowType, method string) (string, trace.EventOption) {
|
||||
return RecoveryInitiatedByAdmin.String(),
|
||||
trace.WithAttributes(append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
attrSelfServiceFlowType(flowType),
|
||||
semconv.AttrIdentityID(identityID),
|
||||
attrSelfServiceMethodUsed(method),
|
||||
attrFlowID(flowID),
|
||||
)...)
|
||||
}
|
||||
|
||||
func NewSettingsSucceeded(ctx context.Context, flowID, identityID uuid.UUID, flowType, method string) (string, trace.EventOption) {
|
||||
return SettingsSucceeded.String(),
|
||||
trace.WithAttributes(append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
attrSelfServiceFlowType(flowType),
|
||||
semconv.AttrIdentityID(identityID),
|
||||
attrSelfServiceMethodUsed(method),
|
||||
attrFlowID(flowID),
|
||||
)...)
|
||||
}
|
||||
|
||||
func NewVerificationSucceeded(ctx context.Context, flowID, identityID uuid.UUID, flowType, method string) (string, trace.EventOption) {
|
||||
return VerificationSucceeded.String(),
|
||||
trace.WithAttributes(append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
attrSelfServiceMethodUsed(method),
|
||||
attrSelfServiceFlowType(flowType),
|
||||
semconv.AttrIdentityID(identityID),
|
||||
attrFlowID(flowID),
|
||||
)...)
|
||||
}
|
||||
|
||||
func NewRegistrationFailed(ctx context.Context, flowID uuid.UUID, flowType, method string, err error) (string, trace.EventOption) {
|
||||
return RegistrationFailed.String(),
|
||||
trace.WithAttributes(append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
attrSelfServiceFlowType(flowType),
|
||||
attrSelfServiceMethodUsed(method),
|
||||
attrReason(err),
|
||||
attrErrorReason(err),
|
||||
attrFlowID(flowID),
|
||||
)...)
|
||||
}
|
||||
|
||||
func NewRecoveryFailed(ctx context.Context, flowID uuid.UUID, flowType, method string, err error) (string, trace.EventOption) {
|
||||
attrs := append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
attrSelfServiceFlowType(flowType),
|
||||
attrSelfServiceMethodUsed(method),
|
||||
attrReason(err),
|
||||
attrErrorReason(err),
|
||||
attrFlowID(flowID),
|
||||
)
|
||||
|
||||
var identityIDError *x.WithIdentityIDError
|
||||
if errors.As(err, &identityIDError) {
|
||||
attrs = append(attrs, semconv.AttrIdentityID(identityIDError.IdentityID()))
|
||||
}
|
||||
|
||||
return RecoveryFailed.String(), trace.WithAttributes(attrs...)
|
||||
}
|
||||
|
||||
func NewSettingsFailed(ctx context.Context, flowID uuid.UUID, flowType, method string, err error) (string, trace.EventOption) {
|
||||
attrs := append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
attrSelfServiceFlowType(flowType),
|
||||
attrSelfServiceMethodUsed(method),
|
||||
attrReason(err),
|
||||
attrErrorReason(err),
|
||||
attrFlowID(flowID),
|
||||
)
|
||||
|
||||
var identityIDError *x.WithIdentityIDError
|
||||
if errors.As(err, &identityIDError) {
|
||||
attrs = append(attrs, semconv.AttrIdentityID(identityIDError.IdentityID()))
|
||||
}
|
||||
|
||||
return SettingsFailed.String(), trace.WithAttributes(attrs...)
|
||||
}
|
||||
|
||||
func NewVerificationFailed(ctx context.Context, flowID uuid.UUID, flowType, method string, err error) (string, trace.EventOption) {
|
||||
attrs := append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
attrSelfServiceFlowType(flowType),
|
||||
attrSelfServiceMethodUsed(method),
|
||||
attrReason(err),
|
||||
attrErrorReason(err),
|
||||
attrFlowID(flowID),
|
||||
)
|
||||
|
||||
var identityIDError *x.WithIdentityIDError
|
||||
if errors.As(err, &identityIDError) {
|
||||
attrs = append(attrs, semconv.AttrIdentityID(identityIDError.IdentityID()))
|
||||
}
|
||||
|
||||
return VerificationFailed.String(),
|
||||
trace.WithAttributes(attrs...)
|
||||
}
|
||||
|
||||
func NewIdentityCreated(ctx context.Context, identityID uuid.UUID) (string, trace.EventOption) {
|
||||
return IdentityCreated.String(),
|
||||
trace.WithAttributes(
|
||||
append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
semconv.AttrIdentityID(identityID),
|
||||
)...,
|
||||
)
|
||||
}
|
||||
|
||||
func NewIdentityDeleted(ctx context.Context, identityID uuid.UUID) (string, trace.EventOption) {
|
||||
return IdentityDeleted.String(),
|
||||
trace.WithAttributes(
|
||||
append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
semconv.AttrIdentityID(identityID),
|
||||
)...,
|
||||
)
|
||||
}
|
||||
|
||||
func NewIdentityUpdated(ctx context.Context, identityID uuid.UUID) (string, trace.EventOption) {
|
||||
return IdentityUpdated.String(),
|
||||
trace.WithAttributes(
|
||||
append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
semconv.AttrIdentityID(identityID),
|
||||
)...,
|
||||
)
|
||||
}
|
||||
|
||||
func NewLoginFailed(ctx context.Context, flowID uuid.UUID, flowType, method, requestedAAL string, isRefresh bool, err error) (string, trace.EventOption) {
|
||||
attrs := append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
attrSelfServiceFlowType(flowType),
|
||||
attLoginRequestedAAL(requestedAAL),
|
||||
attLoginRequestedPrivilegedSession(isRefresh),
|
||||
attrSelfServiceMethodUsed(method),
|
||||
attrReason(err),
|
||||
attrErrorReason(err),
|
||||
attrFlowID(flowID),
|
||||
)
|
||||
|
||||
var identityIDError *x.WithIdentityIDError
|
||||
if errors.As(err, &identityIDError) {
|
||||
attrs = append(attrs, semconv.AttrIdentityID(identityIDError.IdentityID()))
|
||||
}
|
||||
|
||||
return LoginFailed.String(), trace.WithAttributes(attrs...)
|
||||
}
|
||||
|
||||
func NewSessionRevoked(ctx context.Context, sessionID, identityID uuid.UUID) (string, trace.EventOption) {
|
||||
return SessionRevoked.String(),
|
||||
trace.WithAttributes(
|
||||
append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
semconv.AttrIdentityID(identityID),
|
||||
attrSessionID(sessionID),
|
||||
)...,
|
||||
)
|
||||
}
|
||||
|
||||
func NewSessionChecked(ctx context.Context, sessionID, identityID uuid.UUID) (string, trace.EventOption) {
|
||||
return SessionChecked.String(),
|
||||
trace.WithAttributes(
|
||||
append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
semconv.AttrIdentityID(identityID),
|
||||
attrSessionID(sessionID),
|
||||
)...,
|
||||
)
|
||||
}
|
||||
|
||||
func NewSessionJWTIssued(ctx context.Context, sessionID, identityID uuid.UUID, ttl time.Duration) (string, trace.EventOption) {
|
||||
return SessionTokenizedAsJWT.String(),
|
||||
trace.WithAttributes(
|
||||
append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
semconv.AttrIdentityID(identityID),
|
||||
attrSessionID(sessionID),
|
||||
attrTokenizedSessionTTL(ttl),
|
||||
)...,
|
||||
)
|
||||
}
|
||||
|
||||
func NewWebhookDelivered(ctx context.Context, URL *url.URL, reqBody []byte, status int, resBody []byte, attempt int, requestID, triggerID uuid.UUID, webhookID string) (string, trace.EventOption) {
|
||||
return WebhookDelivered.String(),
|
||||
trace.WithAttributes(
|
||||
append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
attrWebhookReq(reqBody),
|
||||
attrWebhookRes(resBody),
|
||||
attrWebhookStatus(status),
|
||||
attrWebhookURL(URL),
|
||||
attrWebhookAttempt(attempt),
|
||||
attrWebhookRequestID(requestID),
|
||||
attrWebhookID(webhookID),
|
||||
attrWebhookTriggerID(triggerID),
|
||||
)...,
|
||||
)
|
||||
}
|
||||
|
||||
func NewWebhookSucceeded(ctx context.Context, triggerID uuid.UUID, webhookID string) (string, trace.EventOption) {
|
||||
return WebhookSucceeded.String(),
|
||||
trace.WithAttributes(
|
||||
append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
attrWebhookID(webhookID),
|
||||
attrWebhookTriggerID(triggerID),
|
||||
)...)
|
||||
}
|
||||
|
||||
func NewWebhookFailed(ctx context.Context, err error, triggerID uuid.UUID, id string) (string, trace.EventOption) {
|
||||
return WebhookFailed.String(),
|
||||
trace.WithAttributes(
|
||||
append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
attrWebhookID(id),
|
||||
attrWebhookTriggerID(triggerID),
|
||||
otelattr.String("Error", err.Error()),
|
||||
attrErrorReason(err),
|
||||
)...,
|
||||
)
|
||||
}
|
||||
|
||||
// NewJsonnetMappingFailed is used to log errors that occur during the Jsonnet
|
||||
// mapping process. The jsonnetInput and jsonnetOutput is anonymized before
|
||||
// emitting the event.
|
||||
func NewJsonnetMappingFailed(ctx context.Context, err error, jsonnetInput []byte, jsonnetOutput, provider string, method string) (string, trace.EventOption) {
|
||||
attrs := append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
attrErrorReason(err),
|
||||
attrJsonnetInput(jsonnetInput),
|
||||
attrSelfServiceSSOProviderUsed(provider),
|
||||
attrSelfServiceMethodUsed(method),
|
||||
)
|
||||
if jsonnetOutput != "" {
|
||||
attrs = append(attrs, attrJsonnetOutput(jsonnetOutput))
|
||||
}
|
||||
return JsonnetMappingFailed.String(),
|
||||
trace.WithAttributes(
|
||||
attrs...,
|
||||
)
|
||||
}
|
||||
|
||||
func NewLoginInitiated(ctx context.Context, flowID uuid.UUID, flowType string, refresh bool, organizationID uuid.NullUUID, requestedAAL string) (string, trace.EventOption) {
|
||||
attrs := append(semconv.AttributesFromContext(ctx),
|
||||
attrFlowID(flowID),
|
||||
attrSelfServiceFlowType(flowType),
|
||||
attrFlowRefresh(refresh),
|
||||
attrFlowRequestedAAL(requestedAAL),
|
||||
)
|
||||
|
||||
if organizationID.Valid {
|
||||
attrs = append(attrs,
|
||||
attrOrganizationID(organizationID.UUID.String()))
|
||||
}
|
||||
|
||||
return LoginInitiated.String(),
|
||||
trace.WithAttributes(attrs...)
|
||||
}
|
||||
|
||||
func NewRegistrationInitiated(ctx context.Context, flowID uuid.UUID, flowType string, organizationID uuid.NullUUID) (string, trace.EventOption) {
|
||||
attrs := append(semconv.AttributesFromContext(ctx),
|
||||
attrFlowID(flowID),
|
||||
attrSelfServiceFlowType(flowType),
|
||||
)
|
||||
|
||||
if organizationID.Valid {
|
||||
attrs = append(attrs,
|
||||
attrOrganizationID(organizationID.UUID.String()))
|
||||
}
|
||||
|
||||
return RegistrationInitiated.String(),
|
||||
trace.WithAttributes(attrs...)
|
||||
}
|
||||
|
||||
func reasonForError(err error) string {
|
||||
if ve := new(schema.ValidationError); errors.As(err, &ve) {
|
||||
return ve.Message
|
||||
}
|
||||
if r := *new(herodot.ReasonCarrier); errors.As(err, &r) {
|
||||
return r.Reason()
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
func NewCourierMessageAbandoned(ctx context.Context, messageID uuid.UUID, channel string, templateType string) (string, trace.EventOption) {
|
||||
return CourierMessageAbandoned.String(),
|
||||
trace.WithAttributes(
|
||||
append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
attrCourierMessageID(messageID),
|
||||
attrCourierMessageChannel(channel),
|
||||
attrCourierMessageTemplateType(templateType),
|
||||
)...,
|
||||
)
|
||||
}
|
||||
|
||||
func NewCourierMessageDispatched(ctx context.Context, messageID uuid.UUID, channel string, templateType string) (string, trace.EventOption) {
|
||||
return CourierMessageDispatched.String(),
|
||||
trace.WithAttributes(
|
||||
append(
|
||||
semconv.AttributesFromContext(ctx),
|
||||
attrCourierMessageID(messageID),
|
||||
attrCourierMessageChannel(channel),
|
||||
attrCourierMessageTemplateType(templateType),
|
||||
)...,
|
||||
)
|
||||
}
|
||||
270
x/events/events_test.go
Normal file
270
x/events/events_test.go
Normal file
@@ -0,0 +1,270 @@
|
||||
// Copyright © 2025 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package events_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/kratos/x/events"
|
||||
"github.com/ory/x/otelx/semconv"
|
||||
)
|
||||
|
||||
func TestNewJsonnetMappingFailed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
jsonnetInput []byte
|
||||
jsonnetOutput string
|
||||
provider string
|
||||
method identity.CredentialsType
|
||||
expectedAttrs []attribute.KeyValue
|
||||
}{
|
||||
{
|
||||
name: "With all attributes",
|
||||
err: errors.New("test error"),
|
||||
jsonnetInput: []byte(`{"key": "PII value"}`),
|
||||
jsonnetOutput: `{"key": 123}`,
|
||||
provider: "test-provider",
|
||||
method: identity.CredentialsTypeOIDC,
|
||||
expectedAttrs: []attribute.KeyValue{
|
||||
attribute.String("SelfServiceSSOProviderUsed", "test-provider"),
|
||||
attribute.String("SelfServiceMethodUsed", "oidc"),
|
||||
attribute.String("ErrorReason", "test error"),
|
||||
attribute.String("JsonnetInput", `{
|
||||
"key": "string"
|
||||
}`),
|
||||
attribute.String("JsonnetOutput", `{
|
||||
"key": "number"
|
||||
}`),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Without JsonnetOutput",
|
||||
err: errors.New("another error"),
|
||||
jsonnetInput: []byte(`{"key": "PII value"}`),
|
||||
jsonnetOutput: "",
|
||||
provider: "another-provider",
|
||||
method: identity.CredentialsTypeSAML,
|
||||
expectedAttrs: []attribute.KeyValue{
|
||||
attribute.String("SelfServiceSSOProviderUsed", "another-provider"),
|
||||
attribute.String("SelfServiceMethodUsed", "saml"),
|
||||
attribute.String("ErrorReason", "another error"),
|
||||
attribute.String("JsonnetInput", `{
|
||||
"key": "string"
|
||||
}`),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
eventName, opts := events.NewJsonnetMappingFailed(ctx, tt.err, tt.jsonnetInput, tt.jsonnetOutput, tt.provider, tt.method.String())
|
||||
|
||||
assert.Equal(t, events.JsonnetMappingFailed.String(), eventName)
|
||||
|
||||
eventConfig := trace.NewEventConfig(opts)
|
||||
assert.ElementsMatch(t, tt.expectedAttrs, eventConfig.Attributes())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLoginFailed(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
flowID := uuid.Must(uuid.NewV4())
|
||||
identityID := uuid.Must(uuid.NewV4())
|
||||
baseErr := errors.New("login failed")
|
||||
|
||||
t.Run("case=without identity ID", func(t *testing.T) {
|
||||
eventName, opts := events.NewLoginFailed(ctx, flowID, "browser", "password", "aal1", false, baseErr)
|
||||
|
||||
assert.Equal(t, events.LoginFailed.String(), eventName)
|
||||
|
||||
eventConfig := trace.NewEventConfig(opts)
|
||||
attrs := eventConfig.Attributes()
|
||||
|
||||
// Should not contain IdentityID attribute
|
||||
for _, attr := range attrs {
|
||||
assert.NotEqual(t, "IdentityID", string(attr.Key))
|
||||
}
|
||||
|
||||
// Should contain other attributes
|
||||
assert.Contains(t, attrs, attribute.String("SelfServiceFlowType", "browser"))
|
||||
assert.Contains(t, attrs, attribute.String("LoginRequestedAAL", "aal1"))
|
||||
assert.Contains(t, attrs, attribute.Bool("LoginRequestedPrivilegedSession", false))
|
||||
assert.Contains(t, attrs, attribute.String("ErrorReason", "login failed"))
|
||||
assert.Contains(t, attrs, attribute.String("SelfServiceMethodUsed", "password"))
|
||||
})
|
||||
|
||||
t.Run("case=with identity ID", func(t *testing.T) {
|
||||
wrappedErr := x.WrapWithIdentityIDError(baseErr, identityID)
|
||||
eventName, opts := events.NewLoginFailed(ctx, flowID, "browser", "password", "aal1", false, wrappedErr)
|
||||
|
||||
assert.Equal(t, events.LoginFailed.String(), eventName)
|
||||
|
||||
eventConfig := trace.NewEventConfig(opts)
|
||||
attrs := eventConfig.Attributes()
|
||||
|
||||
assert.Contains(t, attrs, attribute.String("IdentityID", identityID.String()))
|
||||
assert.Contains(t, attrs, attribute.String("SelfServiceFlowType", "browser"))
|
||||
assert.Contains(t, attrs, attribute.String("ErrorReason", "login failed"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewRecoveryFailed(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
flowID := uuid.Must(uuid.NewV4())
|
||||
identityID := uuid.Must(uuid.NewV4())
|
||||
baseErr := errors.New("recovery failed")
|
||||
|
||||
t.Run("case=without identity ID", func(t *testing.T) {
|
||||
eventName, opts := events.NewRecoveryFailed(ctx, flowID, "browser", "code", baseErr)
|
||||
|
||||
assert.Equal(t, events.RecoveryFailed.String(), eventName)
|
||||
|
||||
eventConfig := trace.NewEventConfig(opts)
|
||||
attrs := eventConfig.Attributes()
|
||||
|
||||
for _, attr := range attrs {
|
||||
assert.NotEqual(t, "IdentityID", string(attr.Key))
|
||||
}
|
||||
|
||||
assert.Contains(t, attrs, attribute.String("SelfServiceFlowType", "browser"))
|
||||
assert.Contains(t, attrs, attribute.String("SelfServiceMethodUsed", "code"))
|
||||
})
|
||||
|
||||
t.Run("case=with identity ID", func(t *testing.T) {
|
||||
wrappedErr := x.WrapWithIdentityIDError(baseErr, identityID)
|
||||
eventName, opts := events.NewRecoveryFailed(ctx, flowID, "browser", "link", wrappedErr)
|
||||
|
||||
assert.Equal(t, events.RecoveryFailed.String(), eventName)
|
||||
|
||||
eventConfig := trace.NewEventConfig(opts)
|
||||
attrs := eventConfig.Attributes()
|
||||
|
||||
assert.Contains(t, attrs, attribute.String("IdentityID", identityID.String()))
|
||||
assert.Contains(t, attrs, attribute.String("SelfServiceFlowType", "browser"))
|
||||
assert.Contains(t, attrs, attribute.String("SelfServiceMethodUsed", "link"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewSettingsFailed(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
flowID := uuid.Must(uuid.NewV4())
|
||||
identityID := uuid.Must(uuid.NewV4())
|
||||
baseErr := errors.New("settings failed")
|
||||
|
||||
t.Run("case=without identity ID", func(t *testing.T) {
|
||||
eventName, opts := events.NewSettingsFailed(ctx, flowID, "browser", "profile", baseErr)
|
||||
|
||||
assert.Equal(t, events.SettingsFailed.String(), eventName)
|
||||
|
||||
eventConfig := trace.NewEventConfig(opts)
|
||||
attrs := eventConfig.Attributes()
|
||||
|
||||
for _, attr := range attrs {
|
||||
assert.NotEqual(t, "IdentityID", string(attr.Key))
|
||||
}
|
||||
|
||||
assert.Contains(t, attrs, attribute.String("SelfServiceFlowType", "browser"))
|
||||
assert.Contains(t, attrs, attribute.String("SelfServiceMethodUsed", "profile"))
|
||||
})
|
||||
|
||||
t.Run("case=with identity ID", func(t *testing.T) {
|
||||
wrappedErr := x.WrapWithIdentityIDError(baseErr, identityID)
|
||||
eventName, opts := events.NewSettingsFailed(ctx, flowID, "browser", "password", wrappedErr)
|
||||
|
||||
assert.Equal(t, events.SettingsFailed.String(), eventName)
|
||||
|
||||
eventConfig := trace.NewEventConfig(opts)
|
||||
attrs := eventConfig.Attributes()
|
||||
|
||||
assert.Contains(t, attrs, attribute.String("IdentityID", identityID.String()))
|
||||
assert.Contains(t, attrs, attribute.String("SelfServiceFlowType", "browser"))
|
||||
assert.Contains(t, attrs, attribute.String("SelfServiceMethodUsed", "password"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewVerificationFailed(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
flowID := uuid.Must(uuid.NewV4())
|
||||
identityID := uuid.Must(uuid.NewV4())
|
||||
baseErr := errors.New("verification failed")
|
||||
|
||||
t.Run("case=without identity ID", func(t *testing.T) {
|
||||
eventName, opts := events.NewVerificationFailed(ctx, flowID, "browser", "code", baseErr)
|
||||
|
||||
assert.Equal(t, events.VerificationFailed.String(), eventName)
|
||||
|
||||
eventConfig := trace.NewEventConfig(opts)
|
||||
attrs := eventConfig.Attributes()
|
||||
|
||||
for _, attr := range attrs {
|
||||
assert.NotEqual(t, "IdentityID", string(attr.Key))
|
||||
}
|
||||
|
||||
assert.Contains(t, attrs, attribute.String("SelfServiceFlowType", "browser"))
|
||||
assert.Contains(t, attrs, attribute.String("SelfServiceMethodUsed", "code"))
|
||||
})
|
||||
|
||||
t.Run("case=with identity ID", func(t *testing.T) {
|
||||
wrappedErr := x.WrapWithIdentityIDError(baseErr, identityID)
|
||||
eventName, opts := events.NewVerificationFailed(ctx, flowID, "browser", "link", wrappedErr)
|
||||
|
||||
assert.Equal(t, events.VerificationFailed.String(), eventName)
|
||||
|
||||
eventConfig := trace.NewEventConfig(opts)
|
||||
attrs := eventConfig.Attributes()
|
||||
|
||||
assert.Contains(t, attrs, attribute.String("IdentityID", identityID.String()))
|
||||
assert.Contains(t, attrs, attribute.String("SelfServiceFlowType", "browser"))
|
||||
assert.Contains(t, attrs, attribute.String("SelfServiceMethodUsed", "link"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewCourierMessageDispatched(t *testing.T) {
|
||||
t.Parallel()
|
||||
nid := uuid.Must(uuid.NewV4())
|
||||
msgID := uuid.Must(uuid.NewV4())
|
||||
ctx := semconv.ContextWithAttributes(t.Context(), attribute.String("ProjectID", nid.String()))
|
||||
|
||||
eventName, opts := events.NewCourierMessageDispatched(ctx, msgID, "smtp", "stub")
|
||||
|
||||
assert.Equal(t, events.CourierMessageDispatched.String(), eventName)
|
||||
|
||||
eventConfig := trace.NewEventConfig(opts)
|
||||
attrs := eventConfig.Attributes()
|
||||
|
||||
assert.Contains(t, attrs, attribute.String("ProjectID", nid.String()))
|
||||
assert.Contains(t, attrs, attribute.String("CourierMessageID", msgID.String()))
|
||||
assert.Contains(t, attrs, attribute.String("CourierMessageChannel", "smtp"))
|
||||
assert.Contains(t, attrs, attribute.String("CourierMessageTemplateType", "stub"))
|
||||
}
|
||||
|
||||
func TestNewCourierMessageAbandoned(t *testing.T) {
|
||||
t.Parallel()
|
||||
nid := uuid.Must(uuid.NewV4())
|
||||
msgID := uuid.Must(uuid.NewV4())
|
||||
ctx := semconv.ContextWithAttributes(t.Context(), attribute.String("ProjectID", nid.String()))
|
||||
|
||||
eventName, opts := events.NewCourierMessageAbandoned(ctx, msgID, "http", "recovery")
|
||||
|
||||
assert.Equal(t, events.CourierMessageAbandoned.String(), eventName)
|
||||
|
||||
eventConfig := trace.NewEventConfig(opts)
|
||||
attrs := eventConfig.Attributes()
|
||||
|
||||
assert.Contains(t, attrs, attribute.String("ProjectID", nid.String()))
|
||||
assert.Contains(t, attrs, attribute.String("CourierMessageID", msgID.String()))
|
||||
assert.Contains(t, attrs, attribute.String("CourierMessageChannel", "http"))
|
||||
assert.Contains(t, attrs, attribute.String("CourierMessageTemplateType", "recovery"))
|
||||
}
|
||||
10
x/fetcher.go
Normal file
10
x/fetcher.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import "github.com/ory/x/jwksx"
|
||||
|
||||
type JWKSFetchProvider interface {
|
||||
JWKSFetcher() *jwksx.FetcherNext
|
||||
}
|
||||
148
x/http.go
Normal file
148
x/http.go
Normal file
@@ -0,0 +1,148 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/golang/gddo/httputil"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
)
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
var baseURLKey ctxKey
|
||||
|
||||
// WithBaseURL stores the supplied base URL on the context. The URL's scheme
|
||||
// is preserved verbatim — callers that need an https guarantee must enforce
|
||||
// it themselves before calling.
|
||||
func WithBaseURL(ctx context.Context, baseURL *url.URL) context.Context {
|
||||
if baseURL == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, baseURLKey, baseURL)
|
||||
}
|
||||
|
||||
func BaseURLFromContext(ctx context.Context) *url.URL {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
if v := ctx.Value(baseURLKey); v != nil {
|
||||
if u, ok := v.(*url.URL); ok {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BaseURLStringFromContext returns the captured base URL as a string, or an
|
||||
// empty string if none is set. Used to copy the captured URL onto a flow
|
||||
// row at creation time (where it lives next to the rest of the flow rather
|
||||
// than being plumbed via context to every consumer).
|
||||
func BaseURLStringFromContext(ctx context.Context) string {
|
||||
if u := BaseURLFromContext(ctx); u != nil {
|
||||
return u.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CourierBaseURL parses the courier base URL captured on a flow at init time
|
||||
// (recovery.Flow.CourierBaseURL / verification.Flow.CourierBaseURL) and
|
||||
// returns it as a *url.URL. When the captured value is empty or unparseable,
|
||||
// it returns the supplied fallback (typically Config.SelfPublicURL).
|
||||
func CourierBaseURL(courierBaseURL string, fallback *url.URL) *url.URL {
|
||||
if courierBaseURL != "" {
|
||||
if u, err := url.Parse(courierBaseURL); err == nil {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func RequestURL(r *http.Request) *url.URL {
|
||||
source := *r.URL
|
||||
source.Host = cmp.Or(source.Host, r.Header.Get("X-Forwarded-Host"), r.Host)
|
||||
|
||||
if proto := r.Header.Get("X-Forwarded-Proto"); len(proto) > 0 {
|
||||
source.Scheme = proto
|
||||
}
|
||||
|
||||
if source.Scheme == "" {
|
||||
source.Scheme = "https"
|
||||
if r.TLS == nil {
|
||||
source.Scheme = "http"
|
||||
}
|
||||
}
|
||||
|
||||
return &source
|
||||
}
|
||||
|
||||
// RequestBaseURL returns the customer-facing base URL of the request.
|
||||
//
|
||||
// If a base URL was captured on the request context (e.g. by a proxy-aware
|
||||
// middleware that validated an Ory-Base-URL-Rewrite / X-Ory-Original-Host
|
||||
// header), that value wins — it is the URL the end user's browser actually
|
||||
// used, which may differ from the host this service was reached at. This is
|
||||
// the value an OIDC/SAML callback must be redirected back to.
|
||||
//
|
||||
// Otherwise it falls back to the request's own scheme://host[:port] (honoring
|
||||
// X-Forwarded-Host / X-Forwarded-Proto via RequestURL), with no path, query,
|
||||
// or fragment.
|
||||
func RequestBaseURL(r *http.Request) string {
|
||||
if captured := BaseURLStringFromContext(r.Context()); captured != "" {
|
||||
return captured
|
||||
}
|
||||
u := RequestURL(r)
|
||||
return (&url.URL{Scheme: u.Scheme, Host: u.Host}).String()
|
||||
}
|
||||
|
||||
// SendFlowCompletedAsRedirectOrJSON should be used when a login, registration, ... flow has been completed successfully.
|
||||
// It will redirect the user to the provided URL if the request accepts HTML, or return a JSON response if the request is
|
||||
// an SPA request
|
||||
func SendFlowCompletedAsRedirectOrJSON(
|
||||
w http.ResponseWriter, r *http.Request, writer herodot.Writer, out interface{}, redirectTo string,
|
||||
) {
|
||||
sendFlowAsRedirectOrJSON(w, r, writer, out, redirectTo, http.StatusOK)
|
||||
}
|
||||
|
||||
// SendFlowErrorAsRedirectOrJSON should be used when a login, registration, ... flow has errors (e.g. validation errors
|
||||
// or missing data) and should be redirected to the provided URL if the request accepts HTML, or return a JSON response
|
||||
// if the request is an SPA request.
|
||||
func SendFlowErrorAsRedirectOrJSON(
|
||||
w http.ResponseWriter, r *http.Request, writer herodot.Writer, out interface{}, redirectTo string,
|
||||
) {
|
||||
sendFlowAsRedirectOrJSON(w, r, writer, out, redirectTo, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func sendFlowAsRedirectOrJSON(
|
||||
w http.ResponseWriter, r *http.Request, writer herodot.Writer, out interface{}, redirectTo string, jsonResponseCode int,
|
||||
) {
|
||||
switch httputil.NegotiateContentType(r, []string{
|
||||
"text/html",
|
||||
"application/json",
|
||||
}, "text/html") {
|
||||
case "application/json":
|
||||
if err, ok := out.(error); ok {
|
||||
writer.WriteError(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
writer.WriteCode(w, r, jsonResponseCode, out)
|
||||
case "text/html":
|
||||
fallthrough
|
||||
default:
|
||||
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
func AcceptsJSON(r *http.Request) bool {
|
||||
return httputil.NegotiateContentType(r, []string{
|
||||
"text/html",
|
||||
"application/json",
|
||||
}, "text/html") == "application/json"
|
||||
}
|
||||
135
x/http_test.go
Normal file
135
x/http_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/x/assertx"
|
||||
"github.com/ory/x/logrusx"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/ory/x/urlx"
|
||||
)
|
||||
|
||||
func TestWithBaseURL(t *testing.T) {
|
||||
ctx := WithBaseURL(context.Background(), urlx.ParseOrPanic("https://www.ory.com/"))
|
||||
assert.EqualValues(t, "https://www.ory.com/", BaseURLFromContext(ctx).String())
|
||||
assert.Nil(t, BaseURLFromContext(context.Background()))
|
||||
|
||||
t.Run("preserves scheme", func(t *testing.T) {
|
||||
for _, raw := range []string{"http://localhost:4000", "https://example.com/"} {
|
||||
ctx := WithBaseURL(context.Background(), urlx.ParseOrPanic(raw))
|
||||
assert.EqualValuesf(t, raw, BaseURLFromContext(ctx).String(), "for input %q", raw)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("BaseURLStringFromContext", func(t *testing.T) {
|
||||
assert.Equal(t, "", BaseURLStringFromContext(context.Background()))
|
||||
assert.Equal(t, "https://www.ory.com/",
|
||||
BaseURLStringFromContext(WithBaseURL(context.Background(), urlx.ParseOrPanic("https://www.ory.com/"))))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequestURL(t *testing.T) {
|
||||
assert.EqualValues(t, RequestURL(&http.Request{
|
||||
URL: urlx.ParseOrPanic("/foo"), Host: "foobar", TLS: &tls.ConnectionState{},
|
||||
}).String(), "https://foobar/foo")
|
||||
assert.EqualValues(t, RequestURL(&http.Request{
|
||||
URL: urlx.ParseOrPanic("/foo"), Host: "foobar",
|
||||
}).String(), "http://foobar/foo")
|
||||
assert.EqualValues(t, RequestURL(&http.Request{
|
||||
URL: urlx.ParseOrPanic("/foo"), Host: "foobar", Header: http.Header{"X-Forwarded-Host": []string{"notfoobar"}, "X-Forwarded-Proto": {"https"}},
|
||||
}).String(), "https://notfoobar/foo")
|
||||
}
|
||||
|
||||
func TestRequestBaseURL(t *testing.T) {
|
||||
t.Run("falls back to request scheme://host with no path", func(t *testing.T) {
|
||||
assert.Equal(t, "https://foobar", RequestBaseURL(&http.Request{
|
||||
URL: urlx.ParseOrPanic("/self-service/login?foo=bar"), Host: "foobar", TLS: &tls.ConnectionState{},
|
||||
}))
|
||||
assert.Equal(t, "http://foobar", RequestBaseURL(&http.Request{
|
||||
URL: urlx.ParseOrPanic("/foo"), Host: "foobar",
|
||||
}))
|
||||
assert.Equal(t, "https://notfoobar", RequestBaseURL(&http.Request{
|
||||
URL: urlx.ParseOrPanic("/foo"), Host: "foobar",
|
||||
Header: http.Header{"X-Forwarded-Host": []string{"notfoobar"}, "X-Forwarded-Proto": {"https"}},
|
||||
}))
|
||||
})
|
||||
|
||||
t.Run("context-captured customer base URL wins, scheme preserved", func(t *testing.T) {
|
||||
// A proxy-aware middleware (e.g. the cloud courier middleware) validated
|
||||
// an Ory-Base-URL-Rewrite / X-Ory-Original-Host header and stashed the
|
||||
// real customer-facing base URL on the context. The OIDC/SAML state must
|
||||
// capture *that*, not the oryapis host this service was reached at.
|
||||
for _, captured := range []string{"http://localhost:4000", "https://login.customer.example.com"} {
|
||||
req := (&http.Request{URL: urlx.ParseOrPanic("/self-service/login"), Host: "slug.projects.oryapis.com", TLS: &tls.ConnectionState{}}).
|
||||
WithContext(WithBaseURL(context.Background(), urlx.ParseOrPanic(captured)))
|
||||
assert.Equalf(t, captured, RequestBaseURL(req), "captured %q must win over the oryapis host", captured)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAcceptToRedirectOrJSON(t *testing.T) {
|
||||
wr := herodot.NewJSONWriter(logrusx.New("", ""))
|
||||
|
||||
t.Run("case=browser", func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Accept", "text/html")
|
||||
|
||||
t.Run("regular payload", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
SendFlowCompletedAsRedirectOrJSON(w, r, wr, json.RawMessage(`{"foo":"bar"}`), "https://www.ory.com/redir")
|
||||
loc, err := w.Result().Location()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://www.ory.com/redir", loc.String())
|
||||
})
|
||||
|
||||
t.Run("error payload", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
SendFlowCompletedAsRedirectOrJSON(w, r, wr, errors.New("foo"), "https://www.ory.com/redir")
|
||||
loc, err := w.Result().Location()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://www.ory.com/redir", loc.String())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=json", func(t *testing.T) {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Accept", "application/json")
|
||||
|
||||
t.Run("regular payload", func(t *testing.T) {
|
||||
msg := json.RawMessage(`{"foo":"bar"}`)
|
||||
w := httptest.NewRecorder()
|
||||
SendFlowCompletedAsRedirectOrJSON(w, r, wr, msg, "https://www.ory.com/redir")
|
||||
_, err := w.Result().Location()
|
||||
require.ErrorIs(t, err, http.ErrNoLocation)
|
||||
|
||||
body := MustReadAll(w.Result().Body)
|
||||
assertx.EqualAsJSON(t, msg, json.RawMessage(body))
|
||||
})
|
||||
|
||||
t.Run("error payload", func(t *testing.T) {
|
||||
ee := errors.WithStack(herodot.ErrBadRequest())
|
||||
w := httptest.NewRecorder()
|
||||
SendFlowCompletedAsRedirectOrJSON(w, r, wr, ee, "https://www.ory.com/redir")
|
||||
_, err := w.Result().Location()
|
||||
require.ErrorIs(t, err, http.ErrNoLocation)
|
||||
|
||||
body := MustReadAll(w.Result().Body)
|
||||
assertx.EqualAsJSON(t, map[string]interface{}{"error": map[string]interface{}{"code": 400, "message": "The request was malformed or contained invalid parameters", "status": "Bad Request"}}, json.RawMessage(body))
|
||||
assert.EqualValues(t, http.StatusBadRequest, w.Result().StatusCode)
|
||||
})
|
||||
})
|
||||
}
|
||||
24
x/httploadermiddleware.go
Normal file
24
x/httploadermiddleware.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/urfave/negroni"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/ory/jsonschema/v3/httploader"
|
||||
"github.com/ory/x/httpx"
|
||||
)
|
||||
|
||||
func HTTPLoaderContextMiddleware(reg httpx.ClientProvider) negroni.HandlerFunc {
|
||||
return func(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
||||
hc := reg.HTTPClient(r.Context())
|
||||
ctx := context.WithValue(r.Context(), oauth2.HTTPClient, hc)
|
||||
ctx = context.WithValue(ctx, httploader.ContextKey, hc)
|
||||
next(rw, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
8
x/ider.go
Normal file
8
x/ider.go
Normal file
@@ -0,0 +1,8 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
type Ider interface {
|
||||
RequestID() string
|
||||
}
|
||||
22
x/isjsonrequest.go
Normal file
22
x/isjsonrequest.go
Normal file
@@ -0,0 +1,22 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/golang/gddo/httputil"
|
||||
)
|
||||
|
||||
var offers = []string{"text/html", "text/*", "*/*", "application/json"}
|
||||
var defaultOffer = "text/html"
|
||||
|
||||
func IsJSONRequest(r *http.Request) bool {
|
||||
return httputil.NegotiateContentType(r, offers, defaultOffer) == "application/json" ||
|
||||
r.Header.Get("Content-Type") == "application/json"
|
||||
}
|
||||
|
||||
func IsBrowserRequest(r *http.Request) bool {
|
||||
return httputil.NegotiateContentType(r, offers, defaultOffer) == "text/html"
|
||||
}
|
||||
43
x/isjsonrequest_test.go
Normal file
43
x/isjsonrequest_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/gddo/httputil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsBrowserOrAPIRequest(t *testing.T) {
|
||||
for k, tc := range []struct {
|
||||
ua string
|
||||
h string
|
||||
e bool
|
||||
}{
|
||||
{ua: "firefox-66", h: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", e: true},
|
||||
{ua: "safari-chrome", h: "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8", e: true},
|
||||
{ua: "ie8", h: "image/jpeg,application/x-ms-application,image/gif,application/xaml+xml,image/pjpeg,application/x-ms-xbap,application/x-shockwave-flash,application/msword,*/*", e: true},
|
||||
{ua: "ie8-any", h: "*/*", e: true},
|
||||
{ua: "edge", h: "text/html,application/xhtml+xml,image/jxr,*/*", e: true},
|
||||
{ua: "opera", h: "text/html,application/xml;q=0.9,application/xhtml+xml,image/png,image/webp,image/jpeg,image/gif,image/x-xbitmap,*/*;q=0.1", e: true},
|
||||
{ua: "json-api", h: "application/json", e: false},
|
||||
{ua: "no-accept", h: "", e: true},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d/ua=%s", k, tc.ua), func(t *testing.T) {
|
||||
r := &http.Request{Header: map[string][]string{"Accept": {tc.h}}}
|
||||
t.Logf("isBrowser: %s", httputil.NegotiateContentType(r, offers, defaultOffer))
|
||||
|
||||
t.Logf("isJSON: %s", httputil.NegotiateContentType(r,
|
||||
[]string{"application/json"},
|
||||
"text/html",
|
||||
))
|
||||
|
||||
assert.Equal(t, tc.e, IsBrowserRequest(r))
|
||||
assert.Equal(t, !tc.e, IsJSONRequest(r))
|
||||
})
|
||||
}
|
||||
}
|
||||
24
x/json_bool.go
Normal file
24
x/json_bool.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ConvertibleBoolean can unmarshal both booleans and strings.
|
||||
type ConvertibleBoolean bool
|
||||
|
||||
func (bit *ConvertibleBoolean) UnmarshalJSON(data []byte) error {
|
||||
asString := string(data)
|
||||
switch asString {
|
||||
case "true", `"true"`:
|
||||
*bit = true
|
||||
case "false", `"false"`:
|
||||
*bit = false
|
||||
default:
|
||||
return fmt.Errorf("boolean unmarshal error: invalid input %s", asString)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
49
x/json_bool_test.go
Normal file
49
x/json_bool_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type ConvertibleBooleanTest struct {
|
||||
Verified ConvertibleBoolean `json:"verified,omitempty"`
|
||||
}
|
||||
|
||||
func TestUnmarshalBool(t *testing.T) {
|
||||
data := `{"verified":true}`
|
||||
c := ConvertibleBooleanTest{}
|
||||
err := json.Unmarshal([]byte(data), &c)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ConvertibleBoolean(true), c.Verified)
|
||||
|
||||
data = `{"verified":false}`
|
||||
err = json.Unmarshal([]byte(data), &c)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ConvertibleBoolean(false), c.Verified)
|
||||
}
|
||||
|
||||
func TestUnmarshalString(t *testing.T) {
|
||||
data := `{"verified":"true"}`
|
||||
c := ConvertibleBooleanTest{}
|
||||
err := json.Unmarshal([]byte(data), &c)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ConvertibleBoolean(true), c.Verified)
|
||||
|
||||
data = `{"verified":"false"}`
|
||||
err = json.Unmarshal([]byte(data), &c)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ConvertibleBoolean(false), c.Verified)
|
||||
}
|
||||
|
||||
func TestUnmarshalError(t *testing.T) {
|
||||
data := `{"verified":1}`
|
||||
c := ConvertibleBooleanTest{}
|
||||
err := json.Unmarshal([]byte(data), &c)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
18
x/json_marshal.go
Normal file
18
x/json_marshal.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright © 2024 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// ParseRawMessageOrEmpty parses a json.RawMessage and returns an empty map if the input is empty.
|
||||
func ParseRawMessageOrEmpty(input json.RawMessage) (map[string]interface{}, error) {
|
||||
if len(input) == 0 {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(input, &m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
52
x/json_marshal_test.go
Normal file
52
x/json_marshal_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Copyright © 2024 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/kratos/x"
|
||||
)
|
||||
|
||||
func TestParseRawMessageOrEmpty(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
input json.RawMessage
|
||||
expect map[string]interface{}
|
||||
err any
|
||||
}{
|
||||
{
|
||||
input: json.RawMessage("invalid json"),
|
||||
err: "invalid character 'i' looking for beginning of value",
|
||||
},
|
||||
{
|
||||
input: json.RawMessage(""),
|
||||
expect: map[string]interface{}{},
|
||||
},
|
||||
{
|
||||
input: json.RawMessage(`{"foo": "bar"}`),
|
||||
expect: map[string]interface{}{
|
||||
"foo": "bar",
|
||||
},
|
||||
},
|
||||
{
|
||||
input: json.RawMessage(`{"foo": "b`),
|
||||
err: "unexpected end of JSON input",
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("with input '%s'", tc.input), func(t *testing.T) {
|
||||
m, err := x.ParseRawMessageOrEmpty(tc.input)
|
||||
if tc.err != nil {
|
||||
require.Error(t, err)
|
||||
require.Equal(t, tc.err, err.Error())
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.expect, m)
|
||||
})
|
||||
}
|
||||
}
|
||||
66
x/json_number.go
Normal file
66
x/json_number.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
// IsValidNumber reports whether s is a valid JSON number literal.
|
||||
//
|
||||
// Taken from encoding/json
|
||||
func IsValidNumber(s string) bool {
|
||||
// This function implements the JSON numbers grammar.
|
||||
// See https://tools.ietf.org/html/rfc7159#section-6
|
||||
// and https://json.org/number.gif
|
||||
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Optional -
|
||||
if s[0] == '-' {
|
||||
s = s[1:]
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Digits
|
||||
switch {
|
||||
default:
|
||||
return false
|
||||
|
||||
case s[0] == '0':
|
||||
s = s[1:]
|
||||
|
||||
case '1' <= s[0] && s[0] <= '9':
|
||||
s = s[1:]
|
||||
for len(s) > 0 && '0' <= s[0] && s[0] <= '9' {
|
||||
s = s[1:]
|
||||
}
|
||||
}
|
||||
|
||||
// . followed by 1 or more digits.
|
||||
if len(s) >= 2 && s[0] == '.' && '0' <= s[1] && s[1] <= '9' {
|
||||
s = s[2:]
|
||||
for len(s) > 0 && '0' <= s[0] && s[0] <= '9' {
|
||||
s = s[1:]
|
||||
}
|
||||
}
|
||||
|
||||
// e or E followed by an optional - or + and
|
||||
// 1 or more digits.
|
||||
if len(s) >= 2 && (s[0] == 'e' || s[0] == 'E') {
|
||||
s = s[1:]
|
||||
if s[0] == '+' || s[0] == '-' {
|
||||
s = s[1:]
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for len(s) > 0 && '0' <= s[0] && s[0] <= '9' {
|
||||
s = s[1:]
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure we are at the end.
|
||||
return s == ""
|
||||
}
|
||||
79
x/map_json.go
Normal file
79
x/map_json.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
regexMatchInt = regexp.MustCompile(`^[0-9]+$`)
|
||||
regexMatchFloat = regexp.MustCompile(`^[0-9]+\.[0-9]+$`)
|
||||
regexMatchBool = regexp.MustCompile(`^(?i)false|true|on$`)
|
||||
)
|
||||
|
||||
func TypeMap(m map[string]string) (map[string]interface{}, error) {
|
||||
jm := make(map[string]interface{})
|
||||
for k, v := range m {
|
||||
if regexMatchInt.MatchString(v) {
|
||||
vv, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jm[k] = vv
|
||||
} else if regexMatchFloat.MatchString(v) {
|
||||
vv, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jm[k] = vv
|
||||
} else if regexMatchBool.MatchString(v) {
|
||||
// Checkboxes have default values of `on` when checked, so set this to true.
|
||||
if strings.ToLower(v) == "on" {
|
||||
v = "true"
|
||||
}
|
||||
vv, err := strconv.ParseBool(strings.ToLower(v))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jm[k] = vv
|
||||
} else {
|
||||
jm[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return jm, nil
|
||||
}
|
||||
|
||||
func UntypedMapToJSON(m map[string]string) (json.RawMessage, error) {
|
||||
jm, err := TypeMap(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var b bytes.Buffer
|
||||
if err := json.NewEncoder(&b).Encode(jm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
func StructToMap(s interface{}) (map[string]interface{}, error) {
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mapData := make(map[string]interface{})
|
||||
err = json.Unmarshal(b, &mapData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mapData, nil
|
||||
}
|
||||
76
x/map_json_test.go
Normal file
76
x/map_json_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStructToMap(t *testing.T) {
|
||||
m := json.RawMessage(`{"string": "123"}`)
|
||||
r, err := StructToMap(struct {
|
||||
TestString string `json:"string"`
|
||||
TestRaw *json.RawMessage `json:"raw"`
|
||||
}{
|
||||
TestString: "string",
|
||||
TestRaw: &m,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]interface{}{
|
||||
"string": "string",
|
||||
"raw": map[string]interface{}{
|
||||
"string": "123",
|
||||
},
|
||||
}, r)
|
||||
}
|
||||
|
||||
func TestTypeMap(t *testing.T) {
|
||||
r, err := TypeMap(map[string]string{
|
||||
"string": "string",
|
||||
"int": "123",
|
||||
"float": "123.123",
|
||||
"bool": "TrUe",
|
||||
"bool_on": "oN",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, map[string]interface{}{
|
||||
"string": "string",
|
||||
"int": int64(123),
|
||||
"float": 123.123,
|
||||
"bool": true,
|
||||
"bool_on": true,
|
||||
}, r)
|
||||
|
||||
_, err = TypeMap(map[string]string{
|
||||
"int": "999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = TypeMap(map[string]string{
|
||||
"float": "999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999.9",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestUntypedMapToJSON(t *testing.T) {
|
||||
r, err := UntypedMapToJSON(map[string]string{
|
||||
"string": "string",
|
||||
"int": "123",
|
||||
"float": "123.123",
|
||||
"bool": "TrUe",
|
||||
"bool_on": "oN",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"string":"string","int":123,"float":123.123,"bool":true,"bool_on":true}`, string(r))
|
||||
|
||||
_, err = UntypedMapToJSON(map[string]string{
|
||||
"int": "999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999",
|
||||
})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
12
x/maxitems.go
Normal file
12
x/maxitems.go
Normal file
@@ -0,0 +1,12 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
// MaxItemsPerPage is used to prevent DoS attacks against large lists by limiting the items per page to 500.
|
||||
func MaxItemsPerPage(is int) int {
|
||||
if is > 500 {
|
||||
return 500
|
||||
}
|
||||
return is
|
||||
}
|
||||
29
x/nocache.go
Normal file
29
x/nocache.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// NoCache adds `Cache-Control: private, no-cache, no-store, must-revalidate` to the response header.
|
||||
func NoCache(w http.ResponseWriter) {
|
||||
w.Header().Set("Cache-Control", "private, no-cache, no-store, must-revalidate")
|
||||
}
|
||||
|
||||
// NoCacheHandlerFunc wraps http.HandlerFunc with `Cache-Control: private, no-cache, no-store, must-revalidate` headers.
|
||||
func NoCacheHandlerFunc(handle http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
NoCache(w)
|
||||
handle(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// NoCacheHandler wraps http.HandlerFunc with `Cache-Control: private, no-cache, no-store, must-revalidate` headers.
|
||||
func NoCacheHandler(handle http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
NoCache(w)
|
||||
handle.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
167
x/normalize.go
Normal file
167
x/normalize.go
Normal file
@@ -0,0 +1,167 @@
|
||||
// Copyright © 2024 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/nyaruka/phonenumbers"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/runes"
|
||||
"golang.org/x/text/secure/precis"
|
||||
"golang.org/x/text/transform"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
"golang.org/x/text/unicode/rangetable"
|
||||
)
|
||||
|
||||
// disallowedIdentifierRunes is the explicit set of rune categories the
|
||||
// identifier profiles reject in addition to the FreeformClass default
|
||||
// disposition.
|
||||
var disallowedIdentifierRunes = rangetable.Merge(
|
||||
unicode.Bidi_Control, // Directional overrides such as RLO/LRO that can spoof display order.
|
||||
unicode.Other_Default_Ignorable_Code_Point, // Invisible code points like the zero-width joiner that browsers render as nothing.
|
||||
unicode.Other, // Control, format, surrogate, private-use, and unassigned code points.
|
||||
unicode.Noncharacter_Code_Point, // Permanently reserved code points (e.g. U+FFFE) that never represent a character.
|
||||
)
|
||||
|
||||
// emailProfile builds on PRECIS FreeformClass — it permits the punctuation
|
||||
// and spaces that emails need (`@`, `.`, `+`, `-`, `_`) while disallowing
|
||||
// every Default_Ignorable_Code_Point, bidi control, and pictographic symbol.
|
||||
//
|
||||
// `precis.BidiRule` (RFC 5893) is enforced in strict mode so that callers
|
||||
// like `NormalizeIdentifier("email", …)` reject mixed LTR+RTL inputs at
|
||||
// registration time.
|
||||
var emailProfile = precis.NewFreeform(
|
||||
precis.FoldWidth,
|
||||
precis.LowerCase(),
|
||||
precis.DisallowEmpty,
|
||||
precis.BidiRule,
|
||||
precis.Norm(norm.NFKC),
|
||||
precis.Disallow(runes.In(disallowedIdentifierRunes)),
|
||||
)
|
||||
|
||||
// phoneProfile is FreeformClass plus width-fold only. Phone numbers reach
|
||||
// phonenumbers.Parse which performs its own canonicalization to E.164, so
|
||||
// the precis step here only width-folds fullwidth digits and removes any
|
||||
// disallowed runes.
|
||||
var phoneProfile = precis.NewFreeform(precis.FoldWidth)
|
||||
|
||||
func applyPRECIS(profile *precis.Profile, value string, graceful bool) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
out, err := profile.String(value)
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
// Strict mode reports any PRECIS error (disallowed rune or, for the
|
||||
// email profile, an RFC 5893 bidi-rule violation) to the caller.
|
||||
if !graceful {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Graceful mode: strip the runes the profile forbids, then re-run the
|
||||
// normalization pipeline on the cleaned value so casing, width folding,
|
||||
// and NFKC still apply. Skipping the re-run would leave an uppercase or
|
||||
// fullwidth poisoned variant (e.g. "ALICEsmith") distinct from the
|
||||
// clean baseline ("alicesmith") and reopen the duplicate-account bypass.
|
||||
// The bidi-less companion profile is used so mixed LTR+RTL input also
|
||||
// normalizes instead of failing the bidi rule a second time.
|
||||
chain := transform.Chain(
|
||||
norm.NFKC,
|
||||
runes.Remove(runes.Predicate(func(r rune) bool { return !profile.Allowed().Contains(r) })),
|
||||
cases.Lower(language.Und),
|
||||
)
|
||||
stripped, _, err := transform.String(chain, value)
|
||||
if err != nil {
|
||||
return value, nil
|
||||
}
|
||||
return stripped, nil
|
||||
}
|
||||
|
||||
// canonicalizeIdentifier gracefully applies the email profile to the string,
|
||||
// stripping non-printable characters and emojis.
|
||||
func canonicalizeIdentifier(value string) string {
|
||||
v, _ := applyPRECIS(emailProfile, value, true)
|
||||
return v
|
||||
}
|
||||
|
||||
// NormalizeEmailIdentifier normalizes an email address.
|
||||
func NormalizeEmailIdentifier(value string) string {
|
||||
return canonicalizeIdentifier(value)
|
||||
}
|
||||
|
||||
// NormalizePhoneIdentifier normalizes a phone number.
|
||||
func NormalizePhoneIdentifier(value string) string {
|
||||
value, _ = applyPRECIS(phoneProfile, value, true)
|
||||
if number, err := phonenumbers.Parse(value, ""); err == nil && phonenumbers.IsValidNumber(number) {
|
||||
value = phonenumbers.Format(number, phonenumbers.E164)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// NormalizeOtherIdentifier normalizes an identifier that is not an email or phone number.
|
||||
func NormalizeOtherIdentifier(value string) string {
|
||||
return canonicalizeIdentifier(value)
|
||||
}
|
||||
|
||||
// GracefulNormalization normalizes an identifier based on the format.
|
||||
//
|
||||
// Supported formats are:
|
||||
//
|
||||
// - email
|
||||
// - phone
|
||||
// - username
|
||||
func GracefulNormalization(value string) string {
|
||||
value = canonicalizeIdentifier(value)
|
||||
if number, err := phonenumbers.Parse(value, ""); err == nil && phonenumbers.IsValidNumber(number) {
|
||||
return NormalizePhoneIdentifier(value)
|
||||
} else if strings.Contains(value, "@") {
|
||||
return NormalizeEmailIdentifier(value)
|
||||
}
|
||||
return NormalizeOtherIdentifier(value)
|
||||
}
|
||||
|
||||
// NormalizeIdentifier normalizes an identifier based on the format.
|
||||
//
|
||||
// Supported formats are:
|
||||
//
|
||||
// - email
|
||||
// - phone
|
||||
// - username
|
||||
func NormalizeIdentifier(value, format string) (string, error) {
|
||||
switch format {
|
||||
case "email":
|
||||
value, err := applyPRECIS(emailProfile, value, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
email, err := mail.ParseAddress(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return email.Address, nil
|
||||
|
||||
case "sms":
|
||||
value, err := applyPRECIS(phoneProfile, value, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
number, err := phonenumbers.Parse(value, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !phonenumbers.IsValidNumber(number) {
|
||||
return "", errors.New("the provided number is not a valid phone number")
|
||||
}
|
||||
return phonenumbers.Format(number, phonenumbers.E164), nil
|
||||
|
||||
case "username":
|
||||
fallthrough
|
||||
default:
|
||||
return applyPRECIS(emailProfile, value, true)
|
||||
}
|
||||
}
|
||||
317
x/normalize_test.go
Normal file
317
x/normalize_test.go
Normal file
@@ -0,0 +1,317 @@
|
||||
// Copyright © 2024 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// defaultIgnorableBypassRunes is the set of code points called out in the
|
||||
// 2026-05-23 security report as surviving the Cf-only strip. Each rune has
|
||||
// the Default_Ignorable_Code_Point property but lives in a category outside
|
||||
// Cf (Lo, Mn, or Cn), so the predicate from PR #11885 misses them and the
|
||||
// duplicate-account bypass still works.
|
||||
var defaultIgnorableBypassRunes = []struct {
|
||||
name string
|
||||
r rune
|
||||
}{
|
||||
{"Hangul Filler U+3164 (Lo)", 'ㅤ'},
|
||||
{"Hangul Choseong Filler U+115F (Lo)", 'ᅟ'},
|
||||
{"Hangul Jungseong Filler U+1160 (Lo)", 'ᅠ'},
|
||||
{"Khmer Vowel Inherent AQ U+17B4 (Mn)", '឴'},
|
||||
{"Khmer Vowel Inherent AA U+17B5 (Mn)", '឵'},
|
||||
{"Combining Grapheme Joiner U+034F (Mn)", '͏'},
|
||||
{"Reserved U+2065 (Cn, in DI_CP set)", ''},
|
||||
{"Variation Selector-16 U+FE0F (Mn)", '️'},
|
||||
{"Mongolian Free Variation Selector One U+180B (Mn)", '᠋'},
|
||||
{"Variation Selector-17 U+E0100 (Mn)", '\U000E0100'},
|
||||
}
|
||||
|
||||
func TestNormalizeEmailIdentifier(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{" EXAMPLE@DOMAIN.COM ", "example@domain.com"},
|
||||
{"user@domain.com", "user@domain.com"},
|
||||
{"invalid-email", "invalid-email"},
|
||||
|
||||
// Invisible Unicode characters (category Cf) must not produce a
|
||||
// distinct identifier — otherwise an attacker can register a second
|
||||
// account with a visually identical email and bypass the duplicate-
|
||||
// account check.
|
||||
{"alice\u200B@example.com", "alice@example.com"}, // zero-width space
|
||||
{"alice\u200C@example.com", "alice@example.com"}, // zero-width non-joiner
|
||||
{"alice\u200D@example.com", "alice@example.com"}, // zero-width joiner
|
||||
{"Alice\u00AD@example.com", "alice@example.com"}, // soft hyphen
|
||||
{"alice\uFEFF@example.com", "alice@example.com"}, // zero-width no-break space (BOM)
|
||||
{"alice\u2060@example.com", "alice@example.com"}, // word joiner
|
||||
{"alice\u180E@example.com", "alice@example.com"}, // Mongolian vowel separator
|
||||
{"\u202Aalice@example.com\u202C", "alice@example.com"}, // bidi LRE/PDF
|
||||
{"al\u200Bice\u200C@ex\u200Dample.com", "alice@example.com"},
|
||||
|
||||
// NFKC compatibility decomposition collapses fullwidth and ligature
|
||||
// lookalikes so they hit the same identifier as the ASCII canonical
|
||||
// form.
|
||||
{"alice@example.com", "alice@example.com"}, // fullwidth "alice"
|
||||
{"office@example.com", "office@example.com"}, // ligature ffi
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
assert.Equal(t, test.expected, NormalizeEmailIdentifier(test.input), "input=%q", test.input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePhoneIdentifier(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"+1 650-253-0000", "+16502530000"},
|
||||
{"+1 (650) 253-0000", "+16502530000"},
|
||||
{"+44 20 7946 0958", "+442079460958"},
|
||||
{"+49 176 671 11 638", "+4917667111638"},
|
||||
{"+600136866982", "+60136866982"},
|
||||
{"+256 730 691 099", "+256730691099"},
|
||||
{"+256730691099", "+256730691099"},
|
||||
{"invalid-phone", "invalid-phone"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
assert.Equal(t, test.expected, NormalizePhoneIdentifier(test.input))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOtherIdentifier(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{" username ", "username"},
|
||||
{"user123", "user123"},
|
||||
{" ", ""},
|
||||
{" UsERNaME ", "username"},
|
||||
|
||||
// Invisible characters must not allow a duplicate username either.
|
||||
{"al\u200Bice", "alice"}, // U+200B
|
||||
{"al\u200Cice", "alice"}, // U+200C
|
||||
{"al\u200Dice", "alice"}, // U+200D
|
||||
{"al\u00ADice", "alice"}, // U+00AD soft hyphen
|
||||
{"al\uFEFFice", "alice"}, // U+FEFF BOM
|
||||
{"al\u2060ice", "alice"}, // U+2060 word joiner
|
||||
{"al\u180Eice", "alice"}, // U+180E
|
||||
{"alice", "alice"}, // fullwidth Latin (NFKC)
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
assert.Equal(t, test.expected, NormalizeOtherIdentifier(test.input), "input=%q", test.input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGracefulNormalization(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"+1 650-253-0000", "+16502530000"},
|
||||
{" EXAMPLE@DOMAIN.COM ", "example@domain.com"},
|
||||
{" username ", "username"},
|
||||
{"invalid-phone", "invalid-phone"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
assert.Equal(t, test.expected, GracefulNormalization(test.input))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanonicalizeIdentifier_Concurrent exercises the canonicalization
|
||||
// path from many goroutines so the race detector catches any shared
|
||||
// mutable state in the underlying transformer. A package-level
|
||||
// transform.Chain would race on its internal link buffers; a per-call
|
||||
// construction must not.
|
||||
func TestCanonicalizeIdentifier_Concurrent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inputs := []struct {
|
||||
input, want string
|
||||
}{
|
||||
{"alice\u200B@example.com", "alice@example.com"},
|
||||
{"al\u200Cice\u200D@ex\u00ADample.com", "alice@example.com"},
|
||||
{"alice@example.com", "alice@example.com"},
|
||||
{"office@example.com", "office@example.com"},
|
||||
{"al\uFEFFice\u2060@example.com", "alice@example.com"},
|
||||
}
|
||||
|
||||
const goroutines = 64
|
||||
const iterations = 200
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range goroutines {
|
||||
wg.Go(func() {
|
||||
for range iterations {
|
||||
for _, tc := range inputs {
|
||||
got := NormalizeEmailIdentifier(tc.input)
|
||||
if got != tc.want {
|
||||
assert.Equal(t, tc.want, got, "input=%q", tc.input)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestNormalize_DefaultIgnorableBypass asserts that every Default_Ignorable_Code_Point
|
||||
// rune called out in the 2026-05-23 security report is stripped — not just
|
||||
// the Format (Cf) subset that PR #11885 covered. Each rune is injected mid-
|
||||
// identifier; the canonical form must equal the rune-free baseline so that
|
||||
// the UNIQUE constraint on identity_credential_identifiers catches the
|
||||
// duplicate registration.
|
||||
func TestNormalize_DefaultIgnorableBypass(t *testing.T) {
|
||||
for _, tc := range defaultIgnorableBypassRunes {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Username path.
|
||||
gotUser := NormalizeOtherIdentifier("alic" + string(tc.r) + "e")
|
||||
assert.Equal(t, "alice", gotUser,
|
||||
"username with %s must canonicalize to baseline", tc.name)
|
||||
|
||||
// Email path.
|
||||
gotEmail := NormalizeEmailIdentifier("alic" + string(tc.r) + "e@example.com")
|
||||
assert.Equal(t, "alice@example.com", gotEmail,
|
||||
"email with %s must canonicalize to baseline", tc.name)
|
||||
|
||||
// Phone path: the rune must be removed so phonenumbers.Parse
|
||||
// produces the canonical E.164 form.
|
||||
gotPhone := NormalizePhoneIdentifier("+1 650-253" + string(tc.r) + "-0000")
|
||||
assert.Equal(t, "+16502530000", gotPhone,
|
||||
"phone with %s must canonicalize to baseline", tc.name)
|
||||
|
||||
// GracefulNormalization must agree with the format-specific funcs.
|
||||
assert.Equal(t, "alice", GracefulNormalization("alic"+string(tc.r)+"e"))
|
||||
assert.Equal(t, "alice@example.com",
|
||||
GracefulNormalization("alic"+string(tc.r)+"e@example.com"))
|
||||
assert.Equal(t, "+16502530000",
|
||||
GracefulNormalization("+1 650-253"+string(tc.r)+"-0000"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalize_PoisonedRuneStillFolds guards against a duplicate-account
|
||||
// bypass in the graceful fallback: when an identifier carries a disallowed
|
||||
// rune AND uppercase or fullwidth characters, stripping the rune is not
|
||||
// enough — the result must still be lowercased, width-folded, and
|
||||
// NFKC-composed so it collapses onto the clean baseline. Otherwise an
|
||||
// attacker registers `ALICE<ZWSP>smith`, it stores as `ALICEsmith`, and the
|
||||
// UNIQUE constraint never sees it as a duplicate of `alicesmith`.
|
||||
func TestNormalize_PoisonedRuneStillFolds(t *testing.T) {
|
||||
zwsp := string(rune(0x200B))
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
baseline string
|
||||
}{
|
||||
{"uppercase + zero-width space", "ALICE" + zwsp + "SMITH", "alicesmith"},
|
||||
{"fullwidth + zero-width space", "ALICE" + zwsp + "smith", "alicesmith"},
|
||||
{"uppercase + soft hyphen", "FOO" + string(rune(0x00AD)) + "BAR", "foobar"},
|
||||
{"fullwidth digits in email + ZWSP", "AL" + zwsp + "ICE@example.com", "alice@example.com"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.baseline, NormalizeOtherIdentifier(tc.input))
|
||||
assert.Equal(t, NormalizeOtherIdentifier(tc.baseline), NormalizeOtherIdentifier(tc.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalize_EmojiPreserved asserts that emoji and other pictographic
|
||||
// symbols survive normalization rather than being stripped. They are only
|
||||
// lowercased and NFKC-composed, so an identifier carrying an emoji stays
|
||||
// distinct from the emoji-free baseline.
|
||||
func TestNormalize_EmojiPreserved(t *testing.T) {
|
||||
t.Run("emoji is preserved", func(t *testing.T) {
|
||||
assert.Equal(t, "alice🎉", NormalizeOtherIdentifier("alice🎉"))
|
||||
})
|
||||
|
||||
t.Run("emoji identifier is distinct from baseline", func(t *testing.T) {
|
||||
assert.NotEqual(t,
|
||||
NormalizeOtherIdentifier("alice"),
|
||||
NormalizeOtherIdentifier("alice🎉"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// TestNormalize_RTLScripts asserts that identifiers containing RTL
|
||||
// script letters (Hebrew, Arabic) — whether pure-RTL or mixed with LTR —
|
||||
// canonicalize as distinct identifiers. The profile intentionally omits
|
||||
// the RFC 5893 bidi rule so that international users can register email
|
||||
// addresses and usernames in their native script.
|
||||
func TestNormalize_RTLScripts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"pure Hebrew username", "דוד", "דוד"},
|
||||
{"pure Arabic username", "محمد", "محمد"},
|
||||
{"pure Hebrew email", "דוד@example.com", "דוד@example.com"},
|
||||
{"pure Arabic email", "محمد@example.com", "محمد@example.com"},
|
||||
{"mixed LTR+Hebrew username", "aliceא", "aliceא"},
|
||||
{"mixed LTR+Hebrew email", "alice.דוד@example.com", "alice.דוד@example.com"},
|
||||
{"uppercase mixed lowercases", "ALICEא", "aliceא"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.expected, NormalizeOtherIdentifier(tc.input))
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("mixed-script identifier is distinct from LTR-only baseline", func(t *testing.T) {
|
||||
// `aliceא` and `alice` must NOT collide — Hebrew alef is not a
|
||||
// homograph for any Latin letter, so they are different users.
|
||||
assert.NotEqual(t,
|
||||
NormalizeOtherIdentifier("alice"),
|
||||
NormalizeOtherIdentifier("aliceא"),
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("bidi control characters are still stripped", func(t *testing.T) {
|
||||
// Even without `precis.BidiRule`, explicit bidi-control
|
||||
// characters (LRE, PDF, …) are disallowed at the rune level so
|
||||
// they cannot be used to spoof the rendered identifier. The
|
||||
// controls are built from code points so the test source itself
|
||||
// stays free of bidirectional control characters (gosec G116).
|
||||
lre, pdf := string(rune(0x202A)), string(rune(0x202C))
|
||||
assert.Equal(t, "alice@example.com",
|
||||
NormalizeEmailIdentifier(lre+"alice@example.com"+pdf))
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeIdentifier(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
format string
|
||||
expected string
|
||||
err bool
|
||||
}{
|
||||
{" EXAMPLE@DOMAIN.COM ", "email", "example@domain.com", false},
|
||||
{"+1 650-253-0000", "sms", "+16502530000", false},
|
||||
{"+256 730 691 099", "sms", "+256730691099", false},
|
||||
{" username ", "username", "username", false},
|
||||
{"invalid-phone", "sms", "", true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result, err := NormalizeIdentifier(test.input, test.format)
|
||||
if test.err {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, test.expected, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
312
x/nosurfx/nosurf.go
Normal file
312
x/nosurfx/nosurf.go
Normal file
@@ -0,0 +1,312 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nosurfx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/x/httpx"
|
||||
"github.com/ory/x/logrusx"
|
||||
|
||||
"github.com/ory/kratos/text"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/nosurf"
|
||||
"github.com/ory/x/randx"
|
||||
)
|
||||
|
||||
func ErrInvalidCSRFToken() *herodot.DefaultError {
|
||||
return herodot.ErrForbidden().
|
||||
WithID(text.ErrIDCSRF).
|
||||
WithError("the request was rejected to protect you from Cross-Site-Request-Forgery").
|
||||
WithDetail("docs", "https://www.ory.com/docs/troubleshooting/csrf").
|
||||
WithReason("Please retry the flow and optionally clear your cookies. The request was rejected to protect you from Cross-Site-Request-Forgery (CSRF) which could cause account takeover, leaking personal information, and other serious security issues.")
|
||||
}
|
||||
|
||||
func ErrGone() *herodot.DefaultError {
|
||||
return &herodot.DefaultError{
|
||||
CodeField: http.StatusGone,
|
||||
StatusField: http.StatusText(http.StatusGone),
|
||||
ErrorField: "The requested resource is no longer available because it has expired or is otherwise invalid.",
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
noCookie = "The HTTP Cookie Header is empty or not set."
|
||||
cookieMissing = "The HTTP Cookie Header was set but did not include the anti-CSRF cookie."
|
||||
tokenNotSent = "The anti-CSRF cookie was found but the CSRF token was not included in the HTTP request body (" + nosurf.CookieName + ") nor in the HTTP Header (" + nosurf.HeaderName + ")."
|
||||
tokenMismatch = "The HTTP Cookie Header was set and a CSRF token was sent but they do not match. We recommend deleting all cookies for this domain and retrying the flow."
|
||||
)
|
||||
|
||||
func ErrInvalidCSRFTokenAJAX() *herodot.DefaultError {
|
||||
return ErrInvalidCSRFToken().
|
||||
WithDetail("hint", "We detected an AJAX call, please ensure that CORS is enabled and configured correctly, and that your AJAX code sends cookies and has credentials enabled. For further debugging, check your Browser's Network Tab to see what cookies are included or excluded.")
|
||||
}
|
||||
|
||||
func ErrInvalidCSRFTokenAJAXNoCookies() *herodot.DefaultError {
|
||||
return ErrInvalidCSRFTokenAJAX().WithDetail("reject_reason", noCookie)
|
||||
}
|
||||
|
||||
func ErrInvalidCSRFTokenAJAXCookieMissing() *herodot.DefaultError {
|
||||
return ErrInvalidCSRFTokenAJAX().WithDetail("reject_reason", cookieMissing)
|
||||
}
|
||||
|
||||
func ErrInvalidCSRFTokenAJAXTokenNotSent() *herodot.DefaultError {
|
||||
return ErrInvalidCSRFToken().WithDetail("hint", tokenNotSent)
|
||||
}
|
||||
|
||||
func ErrInvalidCSRFTokenAJAXTokenMismatch() *herodot.DefaultError {
|
||||
return ErrInvalidCSRFTokenAJAX().WithDetail("reject_reason", tokenMismatch)
|
||||
}
|
||||
|
||||
func ErrInvalidCSRFTokenServer() *herodot.DefaultError {
|
||||
return ErrInvalidCSRFToken().
|
||||
WithDetail("hint", "We detected a regular browser or server-side call. To debug browser calls check your Browser's Network Tab to see what cookies are included or excluded. If you are calling from a server ensure that the appropriate cookies are being forwarded and that the SDK method is called correctly.")
|
||||
}
|
||||
|
||||
func ErrInvalidCSRFTokenServerNoCookies() *herodot.DefaultError {
|
||||
return ErrInvalidCSRFTokenServer().WithDetail("reject_reason", noCookie)
|
||||
}
|
||||
|
||||
func ErrInvalidCSRFTokenServerCookieMissing() *herodot.DefaultError {
|
||||
return ErrInvalidCSRFTokenServer().WithDetail("reject_reason", cookieMissing)
|
||||
}
|
||||
|
||||
func ErrInvalidCSRFTokenServerTokenNotSent() *herodot.DefaultError {
|
||||
return ErrInvalidCSRFToken().WithDetail("hint", tokenNotSent)
|
||||
}
|
||||
|
||||
func ErrInvalidCSRFTokenServerTokenMismatch() *herodot.DefaultError {
|
||||
return ErrInvalidCSRFTokenServer().WithDetail("reject_reason", tokenMismatch)
|
||||
}
|
||||
|
||||
type CSRFTokenGeneratorProvider interface {
|
||||
GenerateCSRFToken(r *http.Request) string
|
||||
}
|
||||
|
||||
type CSRFToken func(r *http.Request) string
|
||||
|
||||
const CSRFTokenName = "csrf_token"
|
||||
|
||||
func DefaultCSRFToken(r *http.Request) string {
|
||||
return nosurf.Token(r)
|
||||
}
|
||||
|
||||
var FakeCSRFToken = base64.StdEncoding.EncodeToString([]byte(randx.MustString(32, randx.AlphaLowerNum)))
|
||||
|
||||
func FakeCSRFTokenGenerator(r *http.Request) string {
|
||||
return FakeCSRFToken
|
||||
}
|
||||
|
||||
func FakeCSRFTokenGeneratorWithToken(token string) func(r *http.Request) string {
|
||||
return func(r *http.Request) string {
|
||||
return token
|
||||
}
|
||||
}
|
||||
|
||||
var _ nosurf.Handler = new(FakeCSRFHandler)
|
||||
|
||||
type FakeCSRFHandler struct {
|
||||
cookieNameFn func(r *http.Request) string
|
||||
}
|
||||
|
||||
func NewFakeCSRFHandler(reg interface {
|
||||
config.Provider
|
||||
},
|
||||
) *FakeCSRFHandler {
|
||||
return &FakeCSRFHandler{
|
||||
cookieNameFn: func(r *http.Request) string {
|
||||
return CSRFCookieName(r.Context(), reg)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FakeCSRFHandler) DisablePath(s string) {
|
||||
}
|
||||
|
||||
func (f *FakeCSRFHandler) DisableGlob(s string) {
|
||||
}
|
||||
|
||||
func (f *FakeCSRFHandler) DisableGlobs(s ...string) {
|
||||
}
|
||||
|
||||
func (f *FakeCSRFHandler) ExemptPath(s string) {
|
||||
}
|
||||
|
||||
func (f *FakeCSRFHandler) IgnorePath(s string) {
|
||||
}
|
||||
|
||||
func (f *FakeCSRFHandler) IgnoreGlob(s string) {}
|
||||
|
||||
func (f *FakeCSRFHandler) IgnoreGlobs(s ...string) {}
|
||||
|
||||
func (f *FakeCSRFHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {}
|
||||
|
||||
func (f *FakeCSRFHandler) RegenerateToken(_ http.ResponseWriter, _ *http.Request) string {
|
||||
return FakeCSRFToken
|
||||
}
|
||||
|
||||
func WithFakeCSRFCookie(t *testing.T, reg interface {
|
||||
config.Provider
|
||||
}, urlStr string,
|
||||
) (*url.URL, []*http.Cookie) {
|
||||
u, err := url.Parse(urlStr)
|
||||
require.NoError(t, err)
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: CSRFCookieName(t.Context(), reg),
|
||||
Value: FakeCSRFToken,
|
||||
}
|
||||
return u, []*http.Cookie{cookie}
|
||||
}
|
||||
|
||||
type CSRFProvider interface {
|
||||
CSRFHandler() nosurf.Handler
|
||||
}
|
||||
|
||||
func CSRFCookieName(ctx context.Context, reg interface {
|
||||
config.Provider
|
||||
},
|
||||
) string {
|
||||
return "csrf_token_" + fmt.Sprintf("%x", sha256.Sum256([]byte(reg.Config().SelfPublicURL(ctx).String())))
|
||||
}
|
||||
|
||||
func NosurfBaseCookieHandler(reg interface {
|
||||
config.Provider
|
||||
},
|
||||
) func(w http.ResponseWriter, r *http.Request) http.Cookie {
|
||||
return func(w http.ResponseWriter, r *http.Request) http.Cookie {
|
||||
secure := reg.Config().CookieSecure(r.Context())
|
||||
|
||||
sameSite := reg.Config().CookieSameSiteMode(r.Context())
|
||||
if !secure {
|
||||
sameSite = http.SameSiteLaxMode
|
||||
}
|
||||
|
||||
domain := ""
|
||||
if d := reg.Config().CookieDomain(r.Context()); d != "" {
|
||||
domain = d
|
||||
}
|
||||
|
||||
name := CSRFCookieName(r.Context(), reg)
|
||||
cookie := http.Cookie{
|
||||
Name: name,
|
||||
MaxAge: nosurf.MaxAge,
|
||||
Path: reg.Config().CookiePath(r.Context()),
|
||||
Domain: domain,
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: sameSite,
|
||||
}
|
||||
|
||||
if alias := reg.Config().SelfPublicURL(r.Context()); reg.Config().SelfPublicURL(r.Context()).String() != alias.String() {
|
||||
// If a domain alias is detected use that instead.
|
||||
cookie.Domain = alias.Hostname()
|
||||
cookie.Path = alias.Path
|
||||
}
|
||||
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
|
||||
func CSRFErrorReason(r *http.Request, reg interface {
|
||||
config.Provider
|
||||
},
|
||||
) error {
|
||||
// Is it an AJAX request?
|
||||
secFetchMode := r.Header.Get("Sec-Fetch-Mode")
|
||||
isAjax := secFetchMode == "cors" || secFetchMode == "same-origin"
|
||||
|
||||
if len(r.Header.Get("Cookie")) == 0 {
|
||||
if isAjax {
|
||||
return errors.WithStack(ErrInvalidCSRFTokenAJAXNoCookies())
|
||||
}
|
||||
return errors.WithStack(ErrInvalidCSRFTokenServerNoCookies())
|
||||
} else if _, err := r.Cookie(CSRFCookieName(r.Context(), reg)); errors.Is(err, http.ErrNoCookie) {
|
||||
if isAjax {
|
||||
return errors.WithStack(ErrInvalidCSRFTokenAJAXCookieMissing())
|
||||
}
|
||||
return errors.WithStack(ErrInvalidCSRFTokenServerCookieMissing())
|
||||
}
|
||||
|
||||
// We can't parse the request body here, so we check if the content type is JSON,
|
||||
// as we can't return a meaningful error in that case.
|
||||
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
||||
if len(r.Form.Get("csrf_token")+r.Header.Get(nosurf.HeaderName)) == 0 {
|
||||
if isAjax {
|
||||
return errors.WithStack(ErrInvalidCSRFTokenAJAXTokenNotSent())
|
||||
}
|
||||
return errors.WithStack(ErrInvalidCSRFTokenServerTokenNotSent())
|
||||
}
|
||||
}
|
||||
|
||||
if isAjax {
|
||||
return errors.WithStack(ErrInvalidCSRFTokenAJAXTokenMismatch())
|
||||
}
|
||||
return errors.WithStack(ErrInvalidCSRFTokenServerTokenMismatch())
|
||||
}
|
||||
|
||||
func CSRFFailureHandler(reg interface {
|
||||
config.Provider
|
||||
logrusx.Provider
|
||||
httpx.WriterProvider
|
||||
},
|
||||
) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
err := CSRFErrorReason(r, reg)
|
||||
reg.Logger().
|
||||
WithError(err).
|
||||
WithField("result", nosurf.VerifyToken(nosurf.Token(r), r.Form.Get("csrf_token"))).
|
||||
WithField("expected_token", nosurf.Token(r)).
|
||||
WithField("received_cookies", r.Cookies()).
|
||||
WithField("received_token_form", r.Form.Get(nosurf.FormFieldName)).
|
||||
WithField("received_token_body", r.PostForm.Get(nosurf.FormFieldName)).
|
||||
WithField("received_token_header", r.Header.Get(nosurf.HeaderName)).
|
||||
Warn("A request failed due to a missing or invalid csrf_token value")
|
||||
|
||||
reg.Writer().WriteError(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
func NewCSRFHandler(
|
||||
router http.Handler,
|
||||
reg interface {
|
||||
config.Provider
|
||||
logrusx.Provider
|
||||
httpx.WriterProvider
|
||||
},
|
||||
) *nosurf.CSRFHandler {
|
||||
n := nosurf.New(router)
|
||||
|
||||
n.SetBaseCookieFunc(NosurfBaseCookieHandler(reg))
|
||||
n.SetFailureHandler(CSRFFailureHandler(reg))
|
||||
return n
|
||||
}
|
||||
|
||||
func NewTestCSRFHandler(router http.Handler, reg interface {
|
||||
WithCSRFHandler(handler nosurf.Handler)
|
||||
WithCSRFTokenGenerator(CSRFToken)
|
||||
httpx.WriterProvider
|
||||
logrusx.Provider
|
||||
config.Provider
|
||||
},
|
||||
) *nosurf.CSRFHandler {
|
||||
n := NewCSRFHandler(router, reg)
|
||||
reg.WithCSRFHandler(n)
|
||||
reg.WithCSRFTokenGenerator(nosurf.Token)
|
||||
return n
|
||||
}
|
||||
189
x/nosurfx/nosurf_test.go
Normal file
189
x/nosurfx/nosurf_test.go
Normal file
@@ -0,0 +1,189 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nosurfx_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/x/assertx"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/nosurf"
|
||||
"github.com/ory/x/randx"
|
||||
)
|
||||
|
||||
func TestNosurfBaseCookieHandler(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
require.NoError(t, conf.Set(ctx, config.ViperKeyPublicBaseURL, "http://foo.com/bar"))
|
||||
|
||||
cookie := nosurfx.NosurfBaseCookieHandler(reg)(httptest.NewRecorder(), httptest.NewRequest("GET", "https://foo/bar", nil))
|
||||
assert.EqualValues(t, "csrf_token_01c86631efd1537ee34a98e75884a6e21dd8e2d9e944934bca21204106bfd32f", cookie.Name, "base64 representation of http://foo.com/bar")
|
||||
assert.EqualValues(t, http.SameSiteLaxMode, cookie.SameSite, "is set to lax because https/secure is false - chrome rejects none samesite on non-https")
|
||||
assert.EqualValues(t, nosurf.MaxAge, cookie.MaxAge)
|
||||
assert.EqualValues(t, "/", cookie.Path, "cookie path is site root by default")
|
||||
assert.EqualValues(t, "", cookie.Domain, "domain for the cookie is set to empty per default")
|
||||
assert.False(t, cookie.Secure, "false because insecure dev mode")
|
||||
assert.True(t, cookie.HttpOnly)
|
||||
|
||||
alNum := regexp.MustCompile("[a-zA-Z_0-9]+")
|
||||
for i := 0; i < 10; i++ {
|
||||
require.NoError(t, conf.Set(ctx, config.ViperKeyPublicBaseURL, randx.MustString(16, randx.AlphaNum)))
|
||||
cookie := nosurfx.NosurfBaseCookieHandler(reg)(httptest.NewRecorder(), httptest.NewRequest("GET", "https://foo/bar", nil))
|
||||
|
||||
assert.NotEqual(t, "aHR0cDovL2Zvby5jb20vYmFy_csrf_token", cookie.Name, "should no longer be http://foo.com/bar")
|
||||
assert.True(t, alNum.MatchString(cookie.Name), "does not have any special chars")
|
||||
}
|
||||
|
||||
require.NoError(t, conf.Set(ctx, config.ViperKeyCookieSameSite, "None"))
|
||||
require.NoError(t, conf.Set(ctx, "dev", false))
|
||||
cookie = nosurfx.NosurfBaseCookieHandler(reg)(httptest.NewRecorder(), httptest.NewRequest("GET", "https://foo/bar", nil))
|
||||
assert.EqualValues(t, http.SameSiteNoneMode, cookie.SameSite, "can be none because https/secure is true")
|
||||
assert.True(t, cookie.Secure, "true because secure mode")
|
||||
assert.True(t, cookie.HttpOnly)
|
||||
}
|
||||
|
||||
func TestNosurfBaseCookieHandlerAliasing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
|
||||
require.NoError(t, conf.Set(ctx, config.ViperKeyPublicBaseURL, "http://foo.com/bar"))
|
||||
|
||||
cookie := nosurfx.NosurfBaseCookieHandler(reg)(httptest.NewRecorder(), httptest.NewRequest("GET", "http://foo.com/bar", nil))
|
||||
assert.EqualValues(t, "", cookie.Domain, "remains unset")
|
||||
assert.EqualValues(t, "/", cookie.Path, "cookie path is site root by default")
|
||||
|
||||
// Check root settings
|
||||
require.NoError(t, conf.Set(ctx, config.ViperKeyCookieDomain, "bar.com"))
|
||||
require.NoError(t, conf.Set(ctx, config.ViperKeyCookiePath, "/baz"))
|
||||
cookie = nosurfx.NosurfBaseCookieHandler(reg)(httptest.NewRecorder(), httptest.NewRequest("GET", "http://foo.com/bar", nil))
|
||||
assert.EqualValues(t, "bar.com", cookie.Domain, "domain doesn't change when request not from an alias but is overwritten by ViperKeyCookieDomain")
|
||||
assert.EqualValues(t, "/baz", cookie.Path, "cookie path is site root by default but is overwritten by ViperKeyCookiePath")
|
||||
}
|
||||
|
||||
func TestNosurfBaseCookieErrorHandler(t *testing.T) {
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
|
||||
h := nosurfx.CSRFFailureHandler(reg)
|
||||
expectError := func(t *testing.T, err error, req *http.Request) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
assertx.EqualAsJSON(t, err, json.RawMessage(gjson.Get(rec.Body.String(), "error").Raw))
|
||||
}
|
||||
|
||||
newAjaxFormRequest := func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "https://foo/bar", nil)
|
||||
req.Header.Set("Sec-Fetch-Mode", "cors")
|
||||
return req
|
||||
}
|
||||
|
||||
newAjaxJSONRequest := func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "https://foo/bar", nil)
|
||||
req.Header.Set("Sec-Fetch-Mode", "cors")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req
|
||||
}
|
||||
|
||||
newBrowserRequest := func() *http.Request {
|
||||
req := httptest.NewRequest("GET", "https://foo/bar", nil)
|
||||
return req
|
||||
}
|
||||
|
||||
t.Run("case=without cookie", func(t *testing.T) {
|
||||
t.Run("source=ajax", func(t *testing.T) {
|
||||
expectError(t, nosurfx.ErrInvalidCSRFTokenAJAXNoCookies(), newAjaxFormRequest())
|
||||
})
|
||||
|
||||
t.Run("source=browser", func(t *testing.T) {
|
||||
expectError(t, nosurfx.ErrInvalidCSRFTokenServerNoCookies(), newBrowserRequest())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=ajax with cookie but without csrf cookie", func(t *testing.T) {
|
||||
test := func(t *testing.T, req *http.Request, err error) {
|
||||
req.Header.Set("Cookie", "foo=bar;")
|
||||
expectError(t, err, req)
|
||||
}
|
||||
|
||||
t.Run("source=ajax", func(t *testing.T) {
|
||||
test(t, newAjaxFormRequest(), nosurfx.ErrInvalidCSRFTokenAJAXCookieMissing())
|
||||
})
|
||||
|
||||
t.Run("source=browser", func(t *testing.T) {
|
||||
test(t, newBrowserRequest(), nosurfx.ErrInvalidCSRFTokenServerCookieMissing())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=with correct cookie but token was not sent in header", func(t *testing.T) {
|
||||
test := func(t *testing.T, req *http.Request, err error) {
|
||||
req.Header.Set("Cookie", nosurfx.CSRFCookieName(req.Context(), reg)+"=bar;")
|
||||
expectError(t, err, req)
|
||||
}
|
||||
|
||||
t.Run("source=ajax", func(t *testing.T) {
|
||||
test(t, newAjaxFormRequest(), nosurfx.ErrInvalidCSRFTokenAJAXTokenNotSent())
|
||||
})
|
||||
|
||||
t.Run("source=ajax json", func(t *testing.T) {
|
||||
test(t, newAjaxJSONRequest(), nosurfx.ErrInvalidCSRFTokenAJAXTokenMismatch())
|
||||
})
|
||||
|
||||
t.Run("source=browser", func(t *testing.T) {
|
||||
test(t, newBrowserRequest(), nosurfx.ErrInvalidCSRFTokenServerTokenNotSent())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=ajax with correct cookie and token in header but they do not match", func(t *testing.T) {
|
||||
test := func(t *testing.T, req *http.Request, err error) {
|
||||
req.Header.Set(nosurf.HeaderName, "bar")
|
||||
req.Header.Set("Cookie", nosurfx.CSRFCookieName(req.Context(), reg)+"=bar;")
|
||||
expectError(t, err, req)
|
||||
}
|
||||
|
||||
t.Run("source=ajax", func(t *testing.T) {
|
||||
test(t, newAjaxFormRequest(), nosurfx.ErrInvalidCSRFTokenAJAXTokenMismatch())
|
||||
})
|
||||
|
||||
t.Run("source=browser", func(t *testing.T) {
|
||||
test(t, newBrowserRequest(), nosurfx.ErrInvalidCSRFTokenServerTokenMismatch())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("case=correct cookie and token in body but they do not match", func(t *testing.T) {
|
||||
test := func(t *testing.T, req *http.Request, err error) {
|
||||
req.Header.Set("Accept", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Cookie", nosurfx.CSRFCookieName(req.Context(), reg)+"=bar;")
|
||||
require.NoError(t, req.ParseForm())
|
||||
expectError(t, err, req)
|
||||
}
|
||||
|
||||
t.Run("source=ajax", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "https://foo/bar", strings.NewReader(url.Values{nosurf.FormFieldName: {"bar"}}.Encode()))
|
||||
req.Header.Set("Sec-Fetch-Mode", "cors")
|
||||
test(t, req, nosurfx.ErrInvalidCSRFTokenAJAXTokenMismatch())
|
||||
})
|
||||
|
||||
t.Run("source=browser", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "https://foo/bar", strings.NewReader(url.Values{nosurf.FormFieldName: {"bar"}}.Encode()))
|
||||
test(t, req, nosurfx.ErrInvalidCSRFTokenServerTokenMismatch())
|
||||
})
|
||||
})
|
||||
}
|
||||
65
x/pagination.go
Normal file
65
x/pagination.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/x/pagination/keysetpagination"
|
||||
"github.com/ory/x/pagination/migrationpagination"
|
||||
"github.com/ory/x/pagination/pagepagination"
|
||||
)
|
||||
|
||||
// The format we need to use in the Page tokens, as it's the only format that is understood by all DBs
|
||||
const MapPaginationDateFormat = "2006-01-02 15:04:05.99999"
|
||||
|
||||
// ParsePagination parses limit and page from *http.Request with given limits and defaults.
|
||||
func ParsePagination(r *http.Request) (page, itemsPerPage int) {
|
||||
return migrationpagination.NewDefaultPaginator().ParsePagination(r)
|
||||
}
|
||||
|
||||
func PaginationHeader(w http.ResponseWriter, u url.URL, total int64, page, itemsPerPage int) {
|
||||
migrationpagination.PaginationHeader(w, &u, total, page, itemsPerPage)
|
||||
}
|
||||
|
||||
type Page struct {
|
||||
Page, ItemsPerPage int
|
||||
}
|
||||
|
||||
var PagePaginationLimit = 1000
|
||||
|
||||
func ParseKeysetOrPagePagination(r *http.Request) ([]keysetpagination.Option, *Page, error) {
|
||||
q := r.URL.Query()
|
||||
// If we have any new-style pagination parameters, use those and ignore the rest.
|
||||
if q.Has("page_token") || q.Has("page_size") {
|
||||
keyset, err := keysetpagination.Parse(q, keysetpagination.NewStringPageToken)
|
||||
if err != nil {
|
||||
return nil, nil, herodot.ErrBadRequest().WithReason(err.Error())
|
||||
}
|
||||
return keyset, nil, nil
|
||||
}
|
||||
// allow fallback page pagination with upper limit
|
||||
if q.Has("page") {
|
||||
paginator := pagepagination.PagePaginator{MaxItems: 500, DefaultItems: 250}
|
||||
page, perPage := paginator.ParsePagination(r)
|
||||
if page*perPage > PagePaginationLimit {
|
||||
return nil, nil, herodot.ErrBadRequest().WithReasonf("Legacy pagination is not supported for enumerating over %d items. Please switch to using page_token and page_size.", PagePaginationLimit)
|
||||
}
|
||||
return nil, &Page{page, perPage}, nil
|
||||
}
|
||||
// Allow passing per_page instead of page_size if only the former is set...
|
||||
if q.Has("per_page") && !q.Has("page_size") {
|
||||
q.Set("page_size", q.Get("per_page"))
|
||||
q.Del("per_page")
|
||||
r.URL.RawQuery = q.Encode()
|
||||
}
|
||||
// ... and defaul to keyset pagination
|
||||
keyset, err := keysetpagination.Parse(q, keysetpagination.NewStringPageToken)
|
||||
if err != nil {
|
||||
return nil, nil, herodot.ErrBadRequest().WithReason(err.Error())
|
||||
}
|
||||
return keyset, nil, nil
|
||||
}
|
||||
13
x/pointer.go
Normal file
13
x/pointer.go
Normal file
@@ -0,0 +1,13 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import "github.com/gofrs/uuid"
|
||||
|
||||
func PointToUUID(id uuid.UUID) *uuid.UUID {
|
||||
if id == uuid.Nil {
|
||||
return nil
|
||||
}
|
||||
return &id
|
||||
}
|
||||
37
x/provider.go
Normal file
37
x/provider.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
|
||||
"github.com/ory/x/httpx"
|
||||
"github.com/ory/x/logrusx"
|
||||
"github.com/ory/x/otelx"
|
||||
)
|
||||
|
||||
type CookieProvider interface {
|
||||
CookieManager(ctx context.Context) sessions.StoreExact
|
||||
ContinuityCookieManager(ctx context.Context) sessions.StoreExact
|
||||
}
|
||||
|
||||
type BasicRegistry struct {
|
||||
L *logrusx.Logger
|
||||
C *retryablehttp.Client
|
||||
T *otelx.Tracer
|
||||
}
|
||||
|
||||
func (s *BasicRegistry) Tracer(_ context.Context) *otelx.Tracer { return s.T }
|
||||
func (s *BasicRegistry) Logger() *logrusx.Logger { return s.L }
|
||||
|
||||
func (s *BasicRegistry) HTTPClient(_ context.Context, _ ...httpx.ResilientOptions) *retryablehttp.Client {
|
||||
return s.C
|
||||
}
|
||||
|
||||
var _ logrusx.Provider = (*BasicRegistry)(nil)
|
||||
var _ httpx.ClientProvider = (*BasicRegistry)(nil)
|
||||
var _ otelx.Provider = (*BasicRegistry)(nil)
|
||||
16
x/readall.go
Normal file
16
x/readall.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"io"
|
||||
)
|
||||
|
||||
func MustReadAll(r io.Reader) []byte {
|
||||
all, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return all
|
||||
}
|
||||
41
x/redir/port_redirect.go
Normal file
41
x/redir/port_redirect.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package redir
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/x/httprouterx"
|
||||
)
|
||||
|
||||
func RedirectToAdminRoute(reg config.Provider) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
admin := reg.Config().SelfAdminURL(r.Context())
|
||||
|
||||
dest := *r.URL
|
||||
dest.Host = admin.Host
|
||||
dest.Scheme = admin.Scheme
|
||||
dest.Path = strings.TrimPrefix(dest.Path, httprouterx.AdminPrefix)
|
||||
dest.Path = path.Join(admin.Path, httprouterx.AdminPrefix, dest.Path)
|
||||
|
||||
http.Redirect(w, r, dest.String(), http.StatusTemporaryRedirect)
|
||||
}
|
||||
}
|
||||
|
||||
func RedirectToPublicRoute(reg config.Provider) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
public := reg.Config().SelfPublicURL(r.Context())
|
||||
|
||||
dest := *r.URL
|
||||
dest.Host = public.Host
|
||||
dest.Scheme = public.Scheme
|
||||
dest.Path = strings.TrimPrefix(dest.Path, httprouterx.AdminPrefix)
|
||||
dest.Path = path.Join(public.Path, dest.Path)
|
||||
|
||||
http.Redirect(w, r, dest.String(), http.StatusTemporaryRedirect)
|
||||
}
|
||||
}
|
||||
76
x/redir/port_redirect_test.go
Normal file
76
x/redir/port_redirect_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package redir_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/kratos/x/redir"
|
||||
"github.com/ory/x/configx"
|
||||
"github.com/ory/x/httprouterx"
|
||||
)
|
||||
|
||||
func TestRedirectToPublicAdminRoute(t *testing.T) {
|
||||
pub, adm := httprouterx.NewRouterPublic(), httprouterx.NewRouterAdminWithPrefix()
|
||||
|
||||
adminTS := httptest.NewServer(adm)
|
||||
pubTS := httptest.NewServer(pub)
|
||||
t.Cleanup(pubTS.Close)
|
||||
t.Cleanup(adminTS.Close)
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(map[string]any{
|
||||
config.ViperKeyAdminBaseURL: adminTS.URL,
|
||||
config.ViperKeyPublicBaseURL: pubTS.URL,
|
||||
}))
|
||||
|
||||
pub.POST("/privileged", redir.RedirectToAdminRoute(reg))
|
||||
pub.POST("/admin/privileged", redir.RedirectToAdminRoute(reg))
|
||||
adm.POST("/privileged", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(w, r.Body)
|
||||
})
|
||||
|
||||
adm.POST("/read", redir.RedirectToPublicRoute(reg))
|
||||
pub.POST("/read", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(w, r.Body)
|
||||
})
|
||||
|
||||
for k, tc := range []struct {
|
||||
source string
|
||||
dest string
|
||||
}{
|
||||
{
|
||||
source: pubTS.URL + "/privileged?foo=bar",
|
||||
dest: adminTS.URL + "/admin/privileged?foo=bar",
|
||||
},
|
||||
{
|
||||
source: pubTS.URL + "/admin/privileged?foo=bar",
|
||||
dest: adminTS.URL + "/admin/privileged?foo=bar",
|
||||
},
|
||||
{
|
||||
source: adminTS.URL + "/admin/read?foo=bar",
|
||||
dest: pubTS.URL + "/read?foo=bar",
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%d", k), func(t *testing.T) {
|
||||
id := x.NewUUID().String()
|
||||
res, err := adminTS.Client().Post(tc.source, "", strings.NewReader(id))
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, http.StatusOK, res.StatusCode)
|
||||
assert.Equal(t, tc.dest, res.Request.URL.String())
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, id, string(body))
|
||||
})
|
||||
}
|
||||
}
|
||||
204
x/redir/secure_redirect.go
Normal file
204
x/redir/secure_redirect.go
Normal file
@@ -0,0 +1,204 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package redir
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/ory/kratos/x"
|
||||
|
||||
"github.com/ory/kratos/text"
|
||||
|
||||
"github.com/golang/gddo/httputil"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/x/urlx"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
)
|
||||
|
||||
type secureRedirectOptions struct {
|
||||
allowlist []url.URL
|
||||
defaultReturnTo *url.URL
|
||||
returnTo string
|
||||
sourceURL string
|
||||
}
|
||||
|
||||
type SecureRedirectOption func(*secureRedirectOptions)
|
||||
|
||||
// SecureRedirectAllowURLs allows the given URLs for redirects.
|
||||
func SecureRedirectAllowURLs(urls []url.URL) SecureRedirectOption {
|
||||
return func(o *secureRedirectOptions) {
|
||||
o.allowlist = append(o.allowlist, urls...)
|
||||
}
|
||||
}
|
||||
|
||||
// SecureRedirectUseSourceURL uses the given source URL (checks the `?return_to` value)
|
||||
// instead of r.URL.
|
||||
func SecureRedirectUseSourceURL(source string) SecureRedirectOption {
|
||||
return func(o *secureRedirectOptions) {
|
||||
o.sourceURL = source
|
||||
}
|
||||
}
|
||||
|
||||
// SecureRedirectReturnTo uses the provided URL to redirect the user to it.
|
||||
func SecureRedirectReturnTo(returnTo string) SecureRedirectOption {
|
||||
return func(o *secureRedirectOptions) {
|
||||
o.returnTo = returnTo
|
||||
}
|
||||
}
|
||||
|
||||
// SecureRedirectAllowSelfServiceURLs allows the caller to define `?return_to=` values
|
||||
// which contain the server's URL and `/self-service` path prefix. Useful for redirecting
|
||||
// to the login endpoint, for example.
|
||||
func SecureRedirectAllowSelfServiceURLs(publicURL *url.URL) SecureRedirectOption {
|
||||
return func(o *secureRedirectOptions) {
|
||||
o.allowlist = append(o.allowlist, *urlx.AppendPaths(publicURL, "/self-service"))
|
||||
}
|
||||
}
|
||||
|
||||
// SecureRedirectOverrideDefaultReturnTo overrides the defaultReturnTo address specified
|
||||
// as the second arg.
|
||||
func SecureRedirectOverrideDefaultReturnTo(defaultReturnTo *url.URL) SecureRedirectOption {
|
||||
return func(o *secureRedirectOptions) {
|
||||
o.defaultReturnTo = defaultReturnTo
|
||||
}
|
||||
}
|
||||
|
||||
// SecureRedirectToIsAllowedHost validates if the redirect_to param is allowed for a given wildcard.
|
||||
//
|
||||
// The DNS-label boundary on wildcard hosts is enforced upstream, where the
|
||||
// allow-list is loaded: Config.SelfServiceBrowserAllowedReturnToDomains drops
|
||||
// unsafe wildcards (via corsx.ClassifyOrigin) unless the project is
|
||||
// grandfathered. This function trusts that and only matches; do not re-add a
|
||||
// boundary check here, or grandfathered projects would be enforced
|
||||
// inconsistently.
|
||||
func SecureRedirectToIsAllowedHost(returnTo *url.URL, allowed url.URL) bool {
|
||||
if allowed.Host != "" && allowed.Host[:1] == "*" {
|
||||
return strings.HasSuffix(strings.ToLower(returnTo.Host), strings.ToLower(allowed.Host)[1:])
|
||||
}
|
||||
return strings.EqualFold(allowed.Host, returnTo.Host)
|
||||
}
|
||||
|
||||
// TakeOverReturnToParameter carries over the return_to parameter to a new URL
|
||||
// If `from` does not contain the `return_to` query parameter, the first non-empty value from `fallback` is used instead.
|
||||
func TakeOverReturnToParameter(from string, to string, fallback ...string) (string, error) {
|
||||
fromURL, err := url.Parse(from)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
returnTo := cmp.Or(append([]string{fromURL.Query().Get("return_to")}, fallback...)...)
|
||||
// Empty return_to parameter, return early
|
||||
if returnTo == "" {
|
||||
return to, nil
|
||||
}
|
||||
toURL, err := url.Parse(to)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
toQuery := toURL.Query()
|
||||
toQuery.Set("return_to", returnTo)
|
||||
toURL.RawQuery = toQuery.Encode()
|
||||
return toURL.String(), nil
|
||||
}
|
||||
|
||||
// SecureRedirectTo implements a HTTP redirector who mitigates open redirect vulnerabilities by
|
||||
// working with allow lists.
|
||||
func SecureRedirectTo(r *http.Request, defaultReturnTo *url.URL, opts ...SecureRedirectOption) (returnTo *url.URL, err error) {
|
||||
o := &secureRedirectOptions{defaultReturnTo: defaultReturnTo}
|
||||
for _, opt := range opts {
|
||||
opt(o)
|
||||
}
|
||||
|
||||
if len(o.allowlist) == 0 {
|
||||
return o.defaultReturnTo, nil
|
||||
}
|
||||
|
||||
source := x.RequestURL(r)
|
||||
if o.sourceURL != "" {
|
||||
source, err = url.ParseRequestURI(o.sourceURL)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(herodot.ErrInternalServerError().WithWrap(err).WithReasonf("Unable to parse the original request URL: %s", err))
|
||||
}
|
||||
}
|
||||
|
||||
rawReturnTo := cmp.Or(o.returnTo, source.Query().Get("return_to"))
|
||||
if rawReturnTo == "" {
|
||||
return o.defaultReturnTo, nil
|
||||
}
|
||||
|
||||
returnTo, err = url.Parse(rawReturnTo)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(herodot.ErrBadRequest().WithWrap(err).WithReasonf("Unable to parse the return_to query parameter as an URL: %s", err))
|
||||
}
|
||||
|
||||
returnTo.Host = cmp.Or(returnTo.Host, o.defaultReturnTo.Host)
|
||||
returnTo.Scheme = cmp.Or(returnTo.Scheme, o.defaultReturnTo.Scheme)
|
||||
|
||||
for _, allowed := range o.allowlist {
|
||||
if strings.EqualFold(allowed.Scheme, returnTo.Scheme) &&
|
||||
SecureRedirectToIsAllowedHost(returnTo, allowed) &&
|
||||
strings.HasPrefix(
|
||||
cmp.Or(returnTo.Path, "/"),
|
||||
cmp.Or(allowed.Path, "/")) {
|
||||
return returnTo, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.WithStack(herodot.ErrBadRequest().
|
||||
WithID(text.ErrIDRedirectURLNotAllowed).
|
||||
WithReasonf("Requested return_to URL %q is not allowed.", returnTo),
|
||||
)
|
||||
}
|
||||
|
||||
func SecureContentNegotiationRedirection(
|
||||
w http.ResponseWriter, r *http.Request, out interface{},
|
||||
requestURL string, writer herodot.Writer, c *config.Config,
|
||||
opts ...SecureRedirectOption,
|
||||
) error {
|
||||
switch httputil.NegotiateContentType(r, []string{
|
||||
"text/html",
|
||||
"application/json",
|
||||
}, "text/html") {
|
||||
case "application/json":
|
||||
writer.Write(w, r, out)
|
||||
case "text/html":
|
||||
fallthrough
|
||||
default:
|
||||
ret, err := SecureRedirectTo(r, c.SelfServiceBrowserDefaultReturnTo(r.Context()),
|
||||
append([]SecureRedirectOption{
|
||||
SecureRedirectUseSourceURL(requestURL),
|
||||
SecureRedirectAllowURLs(c.SelfServiceBrowserAllowedReturnToDomains(r.Context())),
|
||||
SecureRedirectAllowSelfServiceURLs(c.SelfPublicURL(r.Context())),
|
||||
}, opts...)...,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
http.Redirect(w, r, ret.String(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ContentNegotiationRedirection(
|
||||
w http.ResponseWriter, r *http.Request, out interface{}, writer herodot.Writer, returnTo string,
|
||||
) {
|
||||
switch httputil.NegotiateContentType(r, []string{
|
||||
"text/html",
|
||||
"application/json",
|
||||
}, "text/html") {
|
||||
case "application/json":
|
||||
writer.Write(w, r, out)
|
||||
case "text/html":
|
||||
fallthrough
|
||||
default:
|
||||
http.Redirect(w, r, returnTo, http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
322
x/redir/secure_redirect_test.go
Normal file
322
x/redir/secure_redirect_test.go
Normal file
@@ -0,0 +1,322 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package redir_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/kratos/x/redir"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
"github.com/ory/x/urlx"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/x"
|
||||
)
|
||||
|
||||
func TestSecureContentNegotiationRedirection(t *testing.T) {
|
||||
conf, _ := pkg.NewFastRegistryWithMocks(t)
|
||||
var jsonActual = json.RawMessage(`{"foo":"bar"}` + "\n")
|
||||
writer := herodot.NewJSONWriter(nil)
|
||||
|
||||
router := http.NewServeMux()
|
||||
router.HandleFunc("GET /redir", func(w http.ResponseWriter, r *http.Request) {
|
||||
require.NoError(t, redir.SecureContentNegotiationRedirection(w, r, jsonActual, x.RequestURL(r).String(), writer, conf))
|
||||
})
|
||||
router.HandleFunc("GET /default-return-to", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
router.HandleFunc("GET /return-to", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(router)
|
||||
defer ts.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
defaultReturnTo := ts.URL + "/default-return-to"
|
||||
conf.MustSet(ctx, config.ViperKeySelfServiceBrowserDefaultReturnTo, defaultReturnTo)
|
||||
conf.MustSet(ctx, config.ViperKeyPublicBaseURL, ts.URL)
|
||||
conf.MustSet(ctx, config.ViperKeyURLsAllowedReturnToDomains, []string{ts.URL})
|
||||
|
||||
run := func(t *testing.T, href string, contentType string) (*http.Response, string) {
|
||||
req, err := http.NewRequest("GET", href, nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Add("Accept", contentType)
|
||||
res, err := ts.Client().Do(req)
|
||||
require.NoError(t, err)
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, res.Body.Close())
|
||||
return res, string(body)
|
||||
}
|
||||
|
||||
t.Run("case=html browser causes redirect", func(t *testing.T) {
|
||||
res, _ := run(t, ts.URL+"/redir", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9")
|
||||
assert.EqualValues(t, res.Request.URL.String(), defaultReturnTo)
|
||||
})
|
||||
|
||||
t.Run("case=html browser causes redirect with redirect_to", func(t *testing.T) {
|
||||
res, _ := run(t, ts.URL+"/redir?return_to=/redirect-to", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9")
|
||||
assert.EqualValues(t, res.Request.URL.String(), ts.URL+"/redirect-to")
|
||||
})
|
||||
|
||||
t.Run("case=html browser causes redirect with redirect_to", func(t *testing.T) {
|
||||
res, body := run(t, ts.URL+"/redir?return_to=/redirect-to", "application/json")
|
||||
assert.EqualValues(t, res.Request.URL.String(), ts.URL+"/redir?return_to=/redirect-to")
|
||||
assert.EqualValues(t, body, jsonActual)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSecureRedirectToIsAllowedHost(t *testing.T) {
|
||||
type testCase struct {
|
||||
allowedURL string
|
||||
redirectURL string
|
||||
valid bool
|
||||
}
|
||||
tests := map[string]testCase{
|
||||
"case=Domain is allowed": {allowedURL: "https://foo.bar", redirectURL: "https://foo.bar/redir", valid: true},
|
||||
"case=Domain prefix is allowed": {allowedURL: "https://*.bar", redirectURL: "https://foo.bar/redir", valid: true},
|
||||
"case=Subdomain prefix is allowed": {allowedURL: "https://*.foo.bar", redirectURL: "https://auth.foo.bar/redir", valid: true},
|
||||
"case=Domain is not allowed": {allowedURL: "https://foo.baz", redirectURL: "https://foo.bar/redir", valid: false},
|
||||
"case=Domain wildcard is not allowed": {allowedURL: "https://*.foo.baz", redirectURL: "https://foo.bar/redir", valid: false},
|
||||
"case=Subdomain is not allowed": {allowedURL: "https://*.foo.baz", redirectURL: "https://auth.foo.bar/redir", valid: false},
|
||||
// A labelled wildcard's leading-dot suffix keeps the label boundary:
|
||||
// subdomains match, suffix-only hosts do not. (Unbound wildcards are
|
||||
// dropped upstream by SelfServiceBrowserAllowedReturnToDomains, so the
|
||||
// matcher never sees them outside grandfathered projects.)
|
||||
"case=Labelled wildcard matches subdomain": {allowedURL: "https://*.foo.com", redirectURL: "https://sub.foo.com/redir", valid: true},
|
||||
"case=Labelled wildcard does not match suffix host": {allowedURL: "https://*.foo.com", redirectURL: "https://evilfoo.com/redir", valid: false},
|
||||
}
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
allowedURL, err := url.Parse(tc.allowedURL)
|
||||
require.NoError(t, err)
|
||||
redirectURL, err := url.Parse(tc.redirectURL)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, redir.SecureRedirectToIsAllowedHost(redirectURL, *allowedURL), tc.valid)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTakeOverReturnToParameter(t *testing.T) {
|
||||
type testCase struct {
|
||||
fromUrl string
|
||||
toURL string
|
||||
expectedOutputUrl string
|
||||
}
|
||||
tests := map[string]testCase{
|
||||
"case=return_to is taken over": {fromUrl: "https://original.bar?return_to=https://allowed.domain", toURL: "https://output.bar", expectedOutputUrl: "https://output.bar?return_to=https%3A%2F%2Fallowed.domain"},
|
||||
"case=only return_to is taken over when multiple query parameters are set": {fromUrl: "https://original.bar?return_to=https://allowed.domain&flow=12312", toURL: "https://output.bar", expectedOutputUrl: "https://output.bar?return_to=https%3A%2F%2Fallowed.domain"},
|
||||
"case=output query parameters are preserved": {fromUrl: "https://original.bar?return_to=https://allowed.domain", toURL: "https://output.bar?flow=123321", expectedOutputUrl: "https://output.bar?flow=123321&return_to=https%3A%2F%2Fallowed.domain"},
|
||||
"case=when original return_to is empty do nothing": {fromUrl: "https://original.bar?return_to=", toURL: "https://output.bar?flow=123123", expectedOutputUrl: "https://output.bar?flow=123123"},
|
||||
}
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
output, err := redir.TakeOverReturnToParameter(tc.fromUrl, tc.toURL)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, output, tc.expectedOutputUrl)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureRedirectTo(t *testing.T) {
|
||||
newServer := func(t *testing.T, isTLS bool, isRelative bool, expectErr bool, opts func(ts *httptest.Server) []redir.SecureRedirectOption) *httptest.Server {
|
||||
var ts *httptest.Server
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if opts == nil {
|
||||
opts = func(ts *httptest.Server) []redir.SecureRedirectOption {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
defaultReturnTo := "/default-return-to"
|
||||
if !isRelative {
|
||||
defaultReturnTo = ts.URL + defaultReturnTo
|
||||
}
|
||||
returnTo, err := redir.SecureRedirectTo(r, urlx.ParseOrPanic(defaultReturnTo), opts(ts)...)
|
||||
if expectErr {
|
||||
require.Error(t, err)
|
||||
_, _ = w.Write([]byte("error"))
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
_, _ = w.Write([]byte(returnTo.String()))
|
||||
})
|
||||
|
||||
if isTLS {
|
||||
ts = httptest.NewTLSServer(handler)
|
||||
} else {
|
||||
ts = httptest.NewServer(handler)
|
||||
}
|
||||
|
||||
t.Cleanup(ts.Close)
|
||||
return ts
|
||||
}
|
||||
|
||||
makeRequest := func(t *testing.T, ts *httptest.Server, path string) (*http.Response, string) {
|
||||
res, err := ts.Client().Get(ts.URL + "/" + path)
|
||||
require.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, res.Body.Close())
|
||||
return res, string(body)
|
||||
}
|
||||
|
||||
t.Run("case=return to a relative path with anchor works", func(t *testing.T) {
|
||||
returnTo, err := redir.SecureRedirectTo(
|
||||
httptest.NewRequest("GET", "/?return_to=/foo/kratos%23abcd", nil),
|
||||
urlx.ParseOrPanic("/default-return-to"),
|
||||
redir.SecureRedirectAllowURLs([]url.URL{*urlx.ParseOrPanic("/foo")}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, returnTo.String(), "/foo/kratos#abcd")
|
||||
})
|
||||
|
||||
t.Run("case=return to default URL if nothing is allowed", func(t *testing.T) {
|
||||
returnTo, err := redir.SecureRedirectTo(
|
||||
httptest.NewRequest("GET", "/?return_to=/foo", nil),
|
||||
urlx.ParseOrPanic("https://www.ory.sh/default-return-to"),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, returnTo.String(), "https://www.ory.sh/default-return-to")
|
||||
})
|
||||
|
||||
t.Run("case=return to foo with server baseURL if allowed", func(t *testing.T) {
|
||||
returnTo, err := redir.SecureRedirectTo(
|
||||
httptest.NewRequest("GET", "/?return_to=/foo", nil),
|
||||
urlx.ParseOrPanic("https://www.ory.sh/default-return-to"),
|
||||
redir.SecureRedirectAllowURLs([]url.URL{*urlx.ParseOrPanic("https://www.ory.sh")}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, returnTo.String(), "https://www.ory.sh/foo")
|
||||
})
|
||||
|
||||
t.Run("case=return to a relative path works", func(t *testing.T) {
|
||||
returnTo, err := redir.SecureRedirectTo(
|
||||
httptest.NewRequest("GET", "/?return_to=/foo/kratos", nil),
|
||||
urlx.ParseOrPanic("/default-return-to"),
|
||||
redir.SecureRedirectAllowURLs([]url.URL{*urlx.ParseOrPanic("/foo")}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, returnTo.String(), "/foo/kratos")
|
||||
})
|
||||
|
||||
t.Run("case=return to a fully qualified domain is forbidden if allowlist is relative", func(t *testing.T) {
|
||||
_, err := redir.SecureRedirectTo(
|
||||
httptest.NewRequest("GET", "/?return_to=https://www.ory.sh/foo/kratos", nil),
|
||||
urlx.ParseOrPanic("/default-return-to"),
|
||||
redir.SecureRedirectAllowURLs([]url.URL{*urlx.ParseOrPanic("/foo")}),
|
||||
)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("case=return to another domain works", func(t *testing.T) {
|
||||
returnTo, err := redir.SecureRedirectTo(
|
||||
httptest.NewRequest("GET", "https://example.com/?return_to=https://www.ory.sh/foo/kratos", nil),
|
||||
urlx.ParseOrPanic("https://www.ory.sh/default-return-to"),
|
||||
redir.SecureRedirectAllowURLs([]url.URL{*urlx.ParseOrPanic("https://www.ory.sh/foo")}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, returnTo.String(), "https://www.ory.sh/foo/kratos")
|
||||
})
|
||||
|
||||
t.Run("case=return to another domain fails if host mismatches", func(t *testing.T) {
|
||||
s := newServer(t, false, false, true, func(ts *httptest.Server) []redir.SecureRedirectOption {
|
||||
return []redir.SecureRedirectOption{redir.SecureRedirectAllowURLs([]url.URL{*urlx.ParseOrPanic("https://www.not-ory.sh/")})}
|
||||
})
|
||||
_, body := makeRequest(t, s, "?return_to=https://www.ory.sh/kratos")
|
||||
assert.Equal(t, body, "error")
|
||||
})
|
||||
|
||||
t.Run("case=return to another domain fails if path mismatches", func(t *testing.T) {
|
||||
s := newServer(t, false, false, true, func(ts *httptest.Server) []redir.SecureRedirectOption {
|
||||
return []redir.SecureRedirectOption{redir.SecureRedirectAllowURLs([]url.URL{*urlx.ParseOrPanic("https://www.ory.sh/not-kratos")})}
|
||||
})
|
||||
_, body := makeRequest(t, s, "?return_to=https://www.ory.sh/kratos")
|
||||
assert.Equal(t, body, "error")
|
||||
})
|
||||
|
||||
t.Run("case=return to another domain fails if scheme mismatches", func(t *testing.T) {
|
||||
s := newServer(t, false, false, true, func(ts *httptest.Server) []redir.SecureRedirectOption {
|
||||
return []redir.SecureRedirectOption{redir.SecureRedirectAllowURLs([]url.URL{*urlx.ParseOrPanic("http://www.ory.sh/")})}
|
||||
})
|
||||
_, body := makeRequest(t, s, "?return_to=https://www.ory.sh/kratos")
|
||||
assert.Equal(t, body, "error")
|
||||
})
|
||||
|
||||
t.Run("case=should work with self-service modifier", func(t *testing.T) {
|
||||
s := newServer(t, false, false, false, func(ts *httptest.Server) []redir.SecureRedirectOption {
|
||||
return []redir.SecureRedirectOption{redir.SecureRedirectAllowSelfServiceURLs(urlx.ParseOrPanic(ts.URL))}
|
||||
})
|
||||
_, body := makeRequest(t, s, "?return_to=/self-service/foo")
|
||||
assert.Equal(t, body, s.URL+"/self-service/foo")
|
||||
})
|
||||
|
||||
t.Run("case=should work with default return to", func(t *testing.T) {
|
||||
s := newServer(t, false, false, false, func(ts *httptest.Server) []redir.SecureRedirectOption {
|
||||
return []redir.SecureRedirectOption{redir.SecureRedirectOverrideDefaultReturnTo(urlx.ParseOrPanic(ts.URL + "/another-default"))}
|
||||
})
|
||||
_, body := makeRequest(t, s, "")
|
||||
assert.Equal(t, body, s.URL+"/another-default")
|
||||
})
|
||||
|
||||
t.Run("case=should override return_to", func(t *testing.T) {
|
||||
s := newServer(t, false, false, false, func(ts *httptest.Server) []redir.SecureRedirectOption {
|
||||
return []redir.SecureRedirectOption{
|
||||
redir.SecureRedirectAllowURLs([]url.URL{*urlx.ParseOrPanic(ts.URL)}),
|
||||
redir.SecureRedirectUseSourceURL("https://foo/bar?return_to=/override"),
|
||||
}
|
||||
})
|
||||
_, body := makeRequest(t, s, "?return_to=/original")
|
||||
assert.Equal(t, body, s.URL+"/override")
|
||||
})
|
||||
|
||||
t.Run("case=should work with subdomain wildcard", func(t *testing.T) {
|
||||
s := newServer(t, false, false, false, func(ts *httptest.Server) []redir.SecureRedirectOption {
|
||||
return []redir.SecureRedirectOption{redir.SecureRedirectAllowURLs([]url.URL{*urlx.ParseOrPanic("https://*.ory.sh/")})}
|
||||
})
|
||||
_, body := makeRequest(t, s, "?return_to=https://www.ory.sh/kratos")
|
||||
assert.Equal(t, body, "https://www.ory.sh/kratos")
|
||||
_, body = makeRequest(t, s, "?return_to=https://even.deeper.nested.ory.sh/kratos")
|
||||
assert.Equal(t, body, "https://even.deeper.nested.ory.sh/kratos")
|
||||
})
|
||||
|
||||
t.Run("case=should fallback to default return_to scheme", func(t *testing.T) {
|
||||
s := newServer(t, false, false, false, func(ts *httptest.Server) []redir.SecureRedirectOption {
|
||||
return []redir.SecureRedirectOption{
|
||||
redir.SecureRedirectAllowURLs([]url.URL{*urlx.ParseOrPanic("https://www.ory.sh")}),
|
||||
redir.SecureRedirectOverrideDefaultReturnTo(urlx.ParseOrPanic("https://www.ory.sh/docs")),
|
||||
}
|
||||
})
|
||||
_, body := makeRequest(t, s, "?return_to=//www.ory.sh/kratos")
|
||||
assert.Equal(t, body, "https://www.ory.sh/kratos")
|
||||
})
|
||||
|
||||
t.Run("case=should fallback to default return_to host", func(t *testing.T) {
|
||||
s := newServer(t, false, false, false, func(ts *httptest.Server) []redir.SecureRedirectOption {
|
||||
return []redir.SecureRedirectOption{
|
||||
redir.SecureRedirectAllowURLs([]url.URL{
|
||||
*urlx.ParseOrPanic("https://www.ory.sh"),
|
||||
*urlx.ParseOrPanic("http://www.ory.sh"),
|
||||
}),
|
||||
redir.SecureRedirectOverrideDefaultReturnTo(urlx.ParseOrPanic("https://www.ory.sh/docs")),
|
||||
}
|
||||
})
|
||||
_, body := makeRequest(t, s, "?return_to=http:///kratos")
|
||||
assert.Equal(t, body, "http://www.ory.sh/kratos")
|
||||
})
|
||||
}
|
||||
18
x/require.go
Normal file
18
x/require.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func RequireJSONMarshal(t *testing.T, in interface{}) []byte {
|
||||
var b bytes.Buffer
|
||||
require.NoError(t, json.NewEncoder(&b).Encode(in))
|
||||
return b.Bytes()
|
||||
}
|
||||
21
x/router.go
Normal file
21
x/router.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"github.com/ory/x/httprouterx"
|
||||
)
|
||||
|
||||
type PublicHandler interface {
|
||||
RegisterPublicRoutes(public *httprouterx.RouterPublic)
|
||||
}
|
||||
|
||||
type AdminHandler interface {
|
||||
RegisterAdminRoutes(admin *httprouterx.RouterAdmin)
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
PublicHandler
|
||||
AdminHandler
|
||||
}
|
||||
10
x/sql.go
Normal file
10
x/sql.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright © 2024 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import "strings"
|
||||
|
||||
func EscapeLikePattern(s string) string {
|
||||
return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(s, "\\", "\\\\"), "%", "\\%"), "_", "\\_")
|
||||
}
|
||||
34
x/sql_test.go
Normal file
34
x/sql_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright © 2024 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEscapeLikePattern(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
"empty": {
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
"no escape": {
|
||||
input: "foo",
|
||||
expected: "foo",
|
||||
},
|
||||
"escape": {
|
||||
input: "foo%bar_baz\\",
|
||||
expected: "foo\\%bar\\_baz\\\\",
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
require.Equal(t, tc.expected, EscapeLikePattern(tc.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
68
x/stub_fs.go
Normal file
68
x/stub_fs.go
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"time"
|
||||
)
|
||||
|
||||
type StubFS struct {
|
||||
name string
|
||||
data []byte
|
||||
offset int
|
||||
}
|
||||
|
||||
func NewStubFS(name string, data []byte) fs.FS {
|
||||
return &StubFS{
|
||||
name: name,
|
||||
data: data,
|
||||
}
|
||||
}
|
||||
|
||||
func (stub *StubFS) Mode() fs.FileMode {
|
||||
return 0444
|
||||
}
|
||||
|
||||
func (stub *StubFS) ModTime() time.Time {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func (stub *StubFS) IsDir() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (stub *StubFS) Sys() interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (stub *StubFS) Stat() (fs.FileInfo, error) {
|
||||
return stub, nil
|
||||
}
|
||||
|
||||
func (stub *StubFS) Read(bytes []byte) (int, error) {
|
||||
if stub.offset >= len(stub.data) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(bytes, stub.data[stub.offset:])
|
||||
stub.offset += n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (stub *StubFS) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (stub *StubFS) Open(name string) (fs.File, error) {
|
||||
return stub, nil
|
||||
}
|
||||
|
||||
func (stub *StubFS) Name() string {
|
||||
return stub.name
|
||||
}
|
||||
|
||||
func (stub *StubFS) Size() int64 {
|
||||
return int64(len(stub.data))
|
||||
}
|
||||
33
x/swagger/swagger_meta.go
Normal file
33
x/swagger/swagger_meta.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Ory Kratos
|
||||
//
|
||||
// Welcome to the Ory Kratos HTTP API documentation!
|
||||
//
|
||||
// Schemes: http, https
|
||||
// Host:
|
||||
// BasePath: /
|
||||
// Version: latest
|
||||
//
|
||||
// Consumes:
|
||||
// - application/json
|
||||
// - application/x-www-form-urlencoded
|
||||
//
|
||||
// Produces:
|
||||
// - application/json
|
||||
//
|
||||
// SecurityDefinitions:
|
||||
// oryAccessToken:
|
||||
// type: apiKey
|
||||
// name: Authorization
|
||||
// in: header
|
||||
//
|
||||
// Extensions:
|
||||
// ---
|
||||
// x-request-id: string
|
||||
// x-forwarded-proto: string
|
||||
// ---
|
||||
//
|
||||
// swagger:meta
|
||||
package swagger
|
||||
26
x/swagger/swagger_types_global.go
Normal file
26
x/swagger/swagger_types_global.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package swagger
|
||||
|
||||
import "github.com/ory/herodot"
|
||||
|
||||
// JSON API Error Response
|
||||
//
|
||||
// The standard Ory JSON API error format.
|
||||
//
|
||||
// swagger:model errorGeneric
|
||||
type ErrorGeneric struct {
|
||||
// Contains error details
|
||||
//
|
||||
// required: true
|
||||
Error GenericError `json:"error"`
|
||||
}
|
||||
|
||||
// swagger:model genericError
|
||||
type GenericError struct{ herodot.DefaultError }
|
||||
|
||||
// Empty responses are sent when, for example, resources are deleted. The HTTP status code for empty responses is typically 204.
|
||||
//
|
||||
// swagger:response emptyResponse
|
||||
type _ struct{}
|
||||
12
x/swagger/swagger_types_overrides.go
Normal file
12
x/swagger/swagger_types_overrides.go
Normal file
@@ -0,0 +1,12 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package swagger
|
||||
|
||||
import "github.com/go-openapi/strfmt"
|
||||
|
||||
// swagger:model UUID
|
||||
//
|
||||
//nolint:deadcode,unused
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type uuid strfmt.UUID4
|
||||
20
x/tests.go
Normal file
20
x/tests.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func MustEncodeJSON(t *testing.T, in interface{}) string {
|
||||
var b bytes.Buffer
|
||||
require.NoError(t, json.NewEncoder(&b).Encode(in))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
const HostedHttpBin = "https://ory-network-httpbin-ijakee5waq-ez.a.run.app"
|
||||
42
x/time.go
Normal file
42
x/time.go
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var rnd = rand.New(rand.NewSource(time.Now().Unix())) //#nosec G404
|
||||
|
||||
func AssertEqualTime(t *testing.T, expected, actual time.Time) {
|
||||
assert.EqualValues(t, expected.UTC().Round(time.Second), actual.UTC().Round(time.Second))
|
||||
}
|
||||
|
||||
func RequireEqualTime(t *testing.T, expected, actual time.Time) {
|
||||
require.EqualValues(t, expected.UTC().Round(time.Second), actual.UTC().Round(time.Second))
|
||||
}
|
||||
|
||||
// RandomDelay returns a time randomly chosen from a normal distribution with mean of base and max/min of base +- deviation
|
||||
// From the docstring for the rand.NormFloat64():
|
||||
// To produce a different normal distribution, callers can
|
||||
// adjust the output using:
|
||||
//
|
||||
// sample = NormFloat64() * desiredStdDev + desiredMean
|
||||
//
|
||||
// Since 99.73% of values in a normal distribution lie within three standard deviations from the mean (https://en.wikipedia.org/wiki/68%E2%80%9395%E2%80%9399.7_rule),
|
||||
// by taking the standard deviation to be deviation/3, we can get a distribution which fits our bounds nicely with minimal clipping when we take max/mins to cut off the tails.
|
||||
func RandomDelay(base, deviation time.Duration) time.Duration {
|
||||
max := float64(base + deviation)
|
||||
min := float64(base - deviation)
|
||||
stddev := float64(deviation) / 3
|
||||
sample := rnd.NormFloat64() * (stddev + float64(base))
|
||||
boundedSample := math.Min(math.Max(sample, min), max)
|
||||
return time.Duration(boundedSample)
|
||||
}
|
||||
21
x/time_test.go
Normal file
21
x/time_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRandomDelay(t *testing.T) {
|
||||
base := time.Millisecond * 2
|
||||
deviation := time.Millisecond
|
||||
for i := 0; i < 100; i++ {
|
||||
delay := RandomDelay(base, deviation)
|
||||
require.LessOrEqual(t, delay, base+deviation)
|
||||
require.GreaterOrEqual(t, delay, base-deviation)
|
||||
}
|
||||
}
|
||||
7
x/token_prefixes.go
Normal file
7
x/token_prefixes.go
Normal file
@@ -0,0 +1,7 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
const OrySessionToken = "ory_st_"
|
||||
const OryLogoutToken = "ory_lo_"
|
||||
23
x/transaction/transaction.go
Normal file
23
x/transaction/transaction.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright © 2024 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package transaction holds the persistence transaction contracts. It is a leaf
|
||||
// package so that importing it does not pull the SQL layer into API-only
|
||||
// consumers such as the CLI.
|
||||
package transaction
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ory/pop/v6"
|
||||
)
|
||||
|
||||
type (
|
||||
PersistenceProvider interface {
|
||||
TransactionalPersisterProvider() Persister
|
||||
}
|
||||
|
||||
Persister interface {
|
||||
Transaction(ctx context.Context, callback func(ctx context.Context, connection *pop.Connection) error) error
|
||||
}
|
||||
)
|
||||
18
x/uuid.go
Normal file
18
x/uuid.go
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package x
|
||||
|
||||
import (
|
||||
"github.com/gofrs/uuid"
|
||||
)
|
||||
|
||||
var EmptyUUID uuid.UUID
|
||||
|
||||
func NewUUID() uuid.UUID {
|
||||
return uuid.Must(uuid.NewV4())
|
||||
}
|
||||
|
||||
func ParseUUID(in string) uuid.UUID {
|
||||
return uuid.FromStringOrNil(in)
|
||||
}
|
||||
55
x/webauthnx/aaguid/aaguid.go
Normal file
55
x/webauthnx/aaguid/aaguid.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//go:generate curl https://raw.githubusercontent.com/passkeydeveloper/passkey-authenticator-aaguids/main/aaguid.json --output passkey-aaguids.json
|
||||
|
||||
package aaguid
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed aaguids.json
|
||||
rawAAGUIDs []byte
|
||||
//go:embed passkey-aaguids.json
|
||||
rawPasskeyAAGUIDs []byte
|
||||
aaguids map[string]AAGUID
|
||||
)
|
||||
|
||||
type AAGUID struct {
|
||||
Name string `json:"name"`
|
||||
IconDark string `json:"icon_dark"`
|
||||
IconLight string `json:"icon_light"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
err := json.Unmarshal(rawAAGUIDs, &aaguids)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
var passkeyAAGUIDs map[string]AAGUID
|
||||
err = json.Unmarshal(rawPasskeyAAGUIDs, &passkeyAAGUIDs)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
maps.Copy(aaguids, passkeyAAGUIDs)
|
||||
}
|
||||
|
||||
func Lookup(id []byte) *AAGUID {
|
||||
uid, err := uuid.FromBytes(id)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if aaguid, ok := aaguids[uid.String()]; ok {
|
||||
return &aaguid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
51
x/webauthnx/aaguid/aaguid_test.go
Normal file
51
x/webauthnx/aaguid/aaguid_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright © 2024 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aaguid
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLookup(t *testing.T) {
|
||||
goodUUIDBytes := uuid.Must(uuid.FromString("adce0002-35bc-c60a-648b-0b25f1f05503")).Bytes()
|
||||
badUUIDBytes := uuid.Must(uuid.NewV4()).Bytes()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id []byte
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "GoodUUID",
|
||||
id: goodUUIDBytes,
|
||||
want: "Chrome on Mac",
|
||||
},
|
||||
{
|
||||
name: "BadUUID",
|
||||
id: badUUIDBytes,
|
||||
},
|
||||
{
|
||||
name: "NilUUID",
|
||||
id: nil,
|
||||
},
|
||||
{
|
||||
name: "EmptyUUID",
|
||||
id: []byte{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := Lookup(tt.id)
|
||||
if tt.want == "" {
|
||||
assert.Nil(t, res)
|
||||
} else {
|
||||
assert.Equal(t, tt.want, res.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
105
x/webauthnx/aaguid/passkey-aaguids.json
Normal file
105
x/webauthnx/aaguid/passkey-aaguids.json
Normal file
File diff suppressed because one or more lines are too long
23
x/webauthnx/errors.go
Normal file
23
x/webauthnx/errors.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package webauthnx
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/jsonschema/v3"
|
||||
)
|
||||
|
||||
var ErrNoCredentials = errors.New("required credentials not found")
|
||||
|
||||
func ErrNotEnoughCredentials() *jsonschema.ValidationError {
|
||||
return &jsonschema.ValidationError{Message: "unable to remove this security key because it would lock you out of your account", InstancePtr: "#/webauthn_remove"}
|
||||
}
|
||||
|
||||
// ErrCredentialAlreadyRegistered is returned when a settings flow submits a credential whose ID
|
||||
// is already registered on the identity. Compliant browsers prevent this via excludeCredentials,
|
||||
// so this guards against clients that ignore the exclusion list.
|
||||
func ErrCredentialAlreadyRegistered(instancePtr string) *jsonschema.ValidationError {
|
||||
return &jsonschema.ValidationError{Message: "this security key or passkey is already registered with your account", InstancePtr: instancePtr}
|
||||
}
|
||||
53
x/webauthnx/handler.go
Normal file
53
x/webauthnx/handler.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package webauthnx
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net/http"
|
||||
|
||||
"github.com/ory/x/httprouterx"
|
||||
)
|
||||
|
||||
//go:embed js/webauthn.js
|
||||
var jsOnLoad []byte
|
||||
|
||||
const ScriptURL = "/.well-known/ory/webauthn.js"
|
||||
|
||||
// swagger:model webAuthnJavaScript
|
||||
//
|
||||
//nolint:deadcode,unused
|
||||
//lint:ignore U1000 Used to generate Swagger and OpenAPI definitions
|
||||
type webAuthnJavaScript string
|
||||
|
||||
// swagger:route GET /.well-known/ory/webauthn.js frontend getWebAuthnJavaScript
|
||||
//
|
||||
// # Get WebAuthn JavaScript
|
||||
//
|
||||
// This endpoint provides JavaScript which is needed in order to perform WebAuthn login and registration.
|
||||
//
|
||||
// If you are building a JavaScript Browser App (e.g. in ReactJS or AngularJS) you will need to load this file:
|
||||
//
|
||||
// ```html
|
||||
// <script src="https://public-kratos.example.org/.well-known/ory/webauthn.js" type="script" async />
|
||||
// ```
|
||||
//
|
||||
// More information can be found at [Ory Kratos User Login](https://www.ory.com/docs/kratos/self-service/flows/user-login) and [User Registration Documentation](https://www.ory.com/docs/kratos/self-service/flows/user-registration).
|
||||
//
|
||||
// Produces:
|
||||
// - text/javascript
|
||||
//
|
||||
// Schemes: http, https
|
||||
//
|
||||
// Responses:
|
||||
// 200: webAuthnJavaScript
|
||||
//
|
||||
// Extensions:
|
||||
// x-ory-ratelimit-bucket: hydra-public-high
|
||||
func RegisterWebauthnRoute(r *httprouterx.RouterPublic) {
|
||||
r.GET(ScriptURL, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/javascript; charset=UTF-8")
|
||||
_, _ = w.Write(jsOnLoad)
|
||||
})
|
||||
}
|
||||
22
x/webauthnx/js/trigger.go
Normal file
22
x/webauthnx/js/trigger.go
Normal file
@@ -0,0 +1,22 @@
|
||||
// Copyright © 2024 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package js
|
||||
|
||||
import "fmt"
|
||||
|
||||
// swagger:enum WebAuthnTriggers
|
||||
type WebAuthnTriggers string
|
||||
|
||||
const (
|
||||
WebAuthnTriggersWebAuthnRegistration WebAuthnTriggers = "oryWebAuthnRegistration"
|
||||
WebAuthnTriggersWebAuthnLogin WebAuthnTriggers = "oryWebAuthnLogin"
|
||||
WebAuthnTriggersPasskeyLogin WebAuthnTriggers = "oryPasskeyLogin"
|
||||
WebAuthnTriggersPasskeyLoginAutocompleteInit WebAuthnTriggers = "oryPasskeyLoginAutocompleteInit"
|
||||
WebAuthnTriggersPasskeyRegistration WebAuthnTriggers = "oryPasskeyRegistration"
|
||||
WebAuthnTriggersPasskeySettingsRegistration WebAuthnTriggers = "oryPasskeySettingsRegistration"
|
||||
)
|
||||
|
||||
func (r WebAuthnTriggers) String() string {
|
||||
return fmt.Sprintf("window.%s", string(r))
|
||||
}
|
||||
14
x/webauthnx/js/trigger_test.go
Normal file
14
x/webauthnx/js/trigger_test.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright © 2024 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package js
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestToString(t *testing.T) {
|
||||
assert.Equal(t, "window.oryWebAuthnRegistration", WebAuthnTriggersWebAuthnRegistration.String())
|
||||
}
|
||||
446
x/webauthnx/js/webauthn.js
Normal file
446
x/webauthnx/js/webauthn.js
Normal file
@@ -0,0 +1,446 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
; (function () {
|
||||
if (!window) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!window.PublicKeyCredential) {
|
||||
console.log("This browser does not support WebAuthn!")
|
||||
return
|
||||
}
|
||||
|
||||
if (window.__oryWebAuthnInitialized) {
|
||||
return
|
||||
}
|
||||
|
||||
function __oryWebAuthnBufferDecode(value) {
|
||||
return Uint8Array.from(
|
||||
atob(value.replaceAll("-", "+").replaceAll("_", "/")),
|
||||
function (c) {
|
||||
return c.charCodeAt(0)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function __oryWebAuthnBufferEncode(value) {
|
||||
return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
|
||||
.replaceAll("+", "-")
|
||||
.replaceAll("/", "_")
|
||||
.replaceAll("=", "")
|
||||
}
|
||||
|
||||
function __oryWebAuthnLogin(
|
||||
options,
|
||||
resultQuerySelector = '*[name="webauthn_login"]',
|
||||
triggerQuerySelector = '*[name="webauthn_login_trigger"]',
|
||||
) {
|
||||
if (!window.PublicKeyCredential) {
|
||||
alert("This browser does not support WebAuthn!")
|
||||
}
|
||||
|
||||
const triggerEl = document.querySelector(triggerQuerySelector)
|
||||
let opt = options
|
||||
if (!opt) {
|
||||
opt = JSON.parse(triggerEl.value)
|
||||
}
|
||||
|
||||
opt.publicKey.challenge = __oryWebAuthnBufferDecode(opt.publicKey.challenge)
|
||||
opt.publicKey.allowCredentials = opt.publicKey.allowCredentials.map(
|
||||
function (value) {
|
||||
return {
|
||||
...value,
|
||||
id: __oryWebAuthnBufferDecode(value.id),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
navigator.credentials
|
||||
.get(opt)
|
||||
.then(function (credential) {
|
||||
document.querySelector(resultQuerySelector).value = JSON.stringify({
|
||||
id: credential.id,
|
||||
rawId: __oryWebAuthnBufferEncode(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
authenticatorData: __oryWebAuthnBufferEncode(
|
||||
credential.response.authenticatorData,
|
||||
),
|
||||
clientDataJSON: __oryWebAuthnBufferEncode(
|
||||
credential.response.clientDataJSON,
|
||||
),
|
||||
signature: __oryWebAuthnBufferEncode(credential.response.signature),
|
||||
userHandle: __oryWebAuthnBufferEncode(
|
||||
credential.response.userHandle,
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
triggerEl.closest("form").submit()
|
||||
})
|
||||
.catch((err) => {
|
||||
alert(err)
|
||||
})
|
||||
}
|
||||
|
||||
function __oryWebAuthnRegistration(
|
||||
options,
|
||||
resultQuerySelector = '*[name="webauthn_register"]',
|
||||
triggerQuerySelector = '*[name="webauthn_register_trigger"]',
|
||||
) {
|
||||
if (!window.PublicKeyCredential) {
|
||||
alert("This browser does not support WebAuthn!")
|
||||
}
|
||||
|
||||
const triggerEl = document.querySelector(triggerQuerySelector)
|
||||
let opt = options
|
||||
if (!opt) {
|
||||
opt = JSON.parse(triggerEl.value)
|
||||
}
|
||||
|
||||
opt.publicKey.user.id = __oryWebAuthnBufferDecode(opt.publicKey.user.id)
|
||||
opt.publicKey.challenge = __oryWebAuthnBufferDecode(opt.publicKey.challenge)
|
||||
|
||||
if (opt.publicKey.excludeCredentials) {
|
||||
opt.publicKey.excludeCredentials = opt.publicKey.excludeCredentials.map(
|
||||
function (value) {
|
||||
return {
|
||||
...value,
|
||||
id: __oryWebAuthnBufferDecode(value.id),
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
navigator.credentials
|
||||
.create(opt)
|
||||
.then(function (credential) {
|
||||
document.querySelector(resultQuerySelector).value = JSON.stringify({
|
||||
id: credential.id,
|
||||
rawId: __oryWebAuthnBufferEncode(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
attestationObject: __oryWebAuthnBufferEncode(
|
||||
credential.response.attestationObject,
|
||||
),
|
||||
clientDataJSON: __oryWebAuthnBufferEncode(
|
||||
credential.response.clientDataJSON,
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
triggerEl.closest("form").submit()
|
||||
})
|
||||
.catch((err) => {
|
||||
alert(err)
|
||||
})
|
||||
}
|
||||
|
||||
async function __oryPasskeyLoginAutocompleteInit() {
|
||||
const dataEl = document.getElementsByName("passkey_challenge")[0]
|
||||
const resultEl = document.getElementsByName("passkey_login")[0]
|
||||
const identifierEl = document.getElementsByName("identifier")[0]
|
||||
|
||||
if (!dataEl || !resultEl || !identifierEl) {
|
||||
console.error(
|
||||
"Unable to initialize WebAuthn / Passkey autocomplete because one or more required form fields are missing.",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!window.PublicKeyCredential ||
|
||||
!window.PublicKeyCredential.isConditionalMediationAvailable ||
|
||||
window.Cypress // Cypress auto-fills the autocomplete, which we don't want
|
||||
) {
|
||||
console.log("This browser does not support Passkey / WebAuthn!")
|
||||
return
|
||||
}
|
||||
|
||||
const isCMA = await PublicKeyCredential.isConditionalMediationAvailable()
|
||||
if (!isCMA) {
|
||||
console.log(
|
||||
"This browser does not support WebAuthn Conditional Mediation!",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let opt = JSON.parse(dataEl.value)
|
||||
|
||||
if (opt.publicKey.user && opt.publicKey.user.id) {
|
||||
opt.publicKey.user.id = __oryWebAuthnBufferDecode(opt.publicKey.user.id)
|
||||
}
|
||||
opt.publicKey.challenge = __oryWebAuthnBufferDecode(opt.publicKey.challenge)
|
||||
|
||||
// If this is set we already have a request ongoing which we need to abort.
|
||||
if (window.abortPasskeyConditionalUI) {
|
||||
window.abortPasskeyConditionalUI.abort(
|
||||
"Canceling Passkey autocomplete to complete trigger-based passkey login.",
|
||||
)
|
||||
window.abortPasskeyConditionalUI = undefined
|
||||
}
|
||||
|
||||
// Allow aborting through a global variable
|
||||
window.abortPasskeyConditionalUI = new AbortController()
|
||||
|
||||
navigator.credentials
|
||||
.get({
|
||||
publicKey: opt.publicKey,
|
||||
mediation: "conditional",
|
||||
signal: abortPasskeyConditionalUI.signal,
|
||||
})
|
||||
.then(function (credential) {
|
||||
resultEl.value = JSON.stringify({
|
||||
id: credential.id,
|
||||
rawId: __oryWebAuthnBufferEncode(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
authenticatorData: __oryWebAuthnBufferEncode(
|
||||
credential.response.authenticatorData,
|
||||
),
|
||||
clientDataJSON: __oryWebAuthnBufferEncode(
|
||||
credential.response.clientDataJSON,
|
||||
),
|
||||
signature: __oryWebAuthnBufferEncode(credential.response.signature),
|
||||
userHandle: __oryWebAuthnBufferEncode(
|
||||
credential.response.userHandle,
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
resultEl.closest("form").submit()
|
||||
})
|
||||
.catch((err) => {
|
||||
console.trace(err)
|
||||
console.log(err)
|
||||
})
|
||||
}
|
||||
|
||||
function __oryPasskeyLogin() {
|
||||
const dataEl = document.getElementsByName("passkey_challenge")[0]
|
||||
const resultEl = document.getElementsByName("passkey_login")[0]
|
||||
|
||||
if (!dataEl || !resultEl) {
|
||||
console.error(
|
||||
"Unable to initialize WebAuthn / Passkey autocomplete because one or more required form fields are missing.",
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!window.PublicKeyCredential) {
|
||||
console.log("This browser does not support WebAuthn!")
|
||||
return
|
||||
}
|
||||
|
||||
let opt = JSON.parse(dataEl.value)
|
||||
|
||||
if (opt.publicKey.user && opt.publicKey.user.id) {
|
||||
opt.publicKey.user.id = __oryWebAuthnBufferDecode(opt.publicKey.user.id)
|
||||
}
|
||||
opt.publicKey.challenge = __oryWebAuthnBufferDecode(opt.publicKey.challenge)
|
||||
if (opt.publicKey.allowCredentials) {
|
||||
opt.publicKey.allowCredentials = opt.publicKey.allowCredentials.map(
|
||||
function (cred) {
|
||||
return {
|
||||
...cred,
|
||||
id: __oryWebAuthnBufferDecode(cred.id),
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (window.abortPasskeyConditionalUI) {
|
||||
window.abortPasskeyConditionalUI.abort(
|
||||
"Canceling Passkey autocomplete to complete trigger-based passkey login.",
|
||||
)
|
||||
window.abortPasskeyConditionalUI = undefined
|
||||
}
|
||||
|
||||
navigator.credentials
|
||||
.get({
|
||||
publicKey: opt.publicKey,
|
||||
})
|
||||
.then(function (credential) {
|
||||
console.trace('login', credential)
|
||||
resultEl.value = JSON.stringify({
|
||||
id: credential.id,
|
||||
rawId: __oryWebAuthnBufferEncode(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
authenticatorData: __oryWebAuthnBufferEncode(
|
||||
credential.response.authenticatorData,
|
||||
),
|
||||
clientDataJSON: __oryWebAuthnBufferEncode(
|
||||
credential.response.clientDataJSON,
|
||||
),
|
||||
signature: __oryWebAuthnBufferEncode(credential.response.signature),
|
||||
userHandle: __oryWebAuthnBufferEncode(
|
||||
credential.response.userHandle,
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
resultEl.closest("form").submit()
|
||||
})
|
||||
.catch((err) => {
|
||||
// Calling this again will enable the autocomplete once again.
|
||||
if (err instanceof DOMException && err.name === "SecurityError") {
|
||||
console.error(`A security exception occurred while loading Passkeys / WebAuthn. To troubleshoot, please head over to https://www.ory.com/docs/troubleshooting/passkeys-webauthn-security-error. The original error message is: ${err.message}`)
|
||||
} else {
|
||||
console.error("[Ory/Passkey] An unknown error occurred while getting passkey credentials", err)
|
||||
}
|
||||
|
||||
console.trace(err)
|
||||
|
||||
// Try re-initializing autocomplete
|
||||
return __oryPasskeyLoginAutocompleteInit()
|
||||
})
|
||||
}
|
||||
|
||||
function __oryPasskeyRegistration() {
|
||||
const dataEl = document.getElementsByName("passkey_create_data")[0]
|
||||
const resultEl = document.getElementsByName("passkey_register")[0]
|
||||
|
||||
if (!dataEl || !resultEl) {
|
||||
console.debug("__oryPasskeyRegistration: mandatory fields not found")
|
||||
return
|
||||
}
|
||||
|
||||
const createData = JSON.parse(dataEl.value)
|
||||
|
||||
// Resolve the passkey display name from the first non-empty candidate field.
|
||||
// Newer servers publish `displayNameFieldNames` (an array of all candidate
|
||||
// field names from the identity schema). Older servers only publish
|
||||
// `displayNameFieldName` (a single field name). Fall back accordingly.
|
||||
const candidateFieldNames =
|
||||
Array.isArray(createData.displayNameFieldNames) &&
|
||||
createData.displayNameFieldNames.length > 0
|
||||
? createData.displayNameFieldNames
|
||||
: [createData.displayNameFieldName]
|
||||
|
||||
const form = dataEl.closest("form")
|
||||
let displayName = ""
|
||||
for (const fieldName of candidateFieldNames) {
|
||||
if (!fieldName) continue
|
||||
const el = form.querySelector("[name='" + fieldName + "']")
|
||||
if (el && el.value) {
|
||||
displayName = el.value
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let opts = createData.credentialOptions
|
||||
opts.publicKey.user.name = displayName
|
||||
opts.publicKey.user.displayName = displayName
|
||||
opts.publicKey.user.id = __oryWebAuthnBufferDecode(opts.publicKey.user.id)
|
||||
opts.publicKey.challenge = __oryWebAuthnBufferDecode(
|
||||
opts.publicKey.challenge,
|
||||
)
|
||||
|
||||
if (opts.publicKey.excludeCredentials) {
|
||||
opts.publicKey.excludeCredentials = opts.publicKey.excludeCredentials.map(
|
||||
function (value) {
|
||||
return {
|
||||
...value,
|
||||
id: __oryWebAuthnBufferDecode(value.id),
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
navigator.credentials
|
||||
.create(opts)
|
||||
.then(function (credential) {
|
||||
resultEl.value = JSON.stringify({
|
||||
id: credential.id,
|
||||
rawId: __oryWebAuthnBufferEncode(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
attestationObject: __oryWebAuthnBufferEncode(
|
||||
credential.response.attestationObject,
|
||||
),
|
||||
clientDataJSON: __oryWebAuthnBufferEncode(
|
||||
credential.response.clientDataJSON,
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
resultEl.closest("form").submit()
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
function __oryPasskeySettingsRegistration() {
|
||||
const dataEl = document.getElementsByName("passkey_create_data")[0]
|
||||
const resultEl = document.getElementsByName("passkey_settings_register")[0]
|
||||
|
||||
if (!dataEl || !resultEl) {
|
||||
console.debug(
|
||||
"__oryPasskeySettingsRegistration: mandatory fields not found",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let opt = JSON.parse(dataEl.value)
|
||||
|
||||
opt.publicKey.user.id = __oryWebAuthnBufferDecode(opt.publicKey.user.id)
|
||||
opt.publicKey.challenge = __oryWebAuthnBufferDecode(opt.publicKey.challenge)
|
||||
|
||||
if (opt.publicKey.excludeCredentials) {
|
||||
opt.publicKey.excludeCredentials = opt.publicKey.excludeCredentials.map(
|
||||
function (value) {
|
||||
return {
|
||||
...value,
|
||||
id: __oryWebAuthnBufferDecode(value.id),
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
navigator.credentials
|
||||
.create(opt)
|
||||
.then(function (credential) {
|
||||
resultEl.value = JSON.stringify({
|
||||
id: credential.id,
|
||||
rawId: __oryWebAuthnBufferEncode(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
attestationObject: __oryWebAuthnBufferEncode(
|
||||
credential.response.attestationObject,
|
||||
),
|
||||
clientDataJSON: __oryWebAuthnBufferEncode(
|
||||
credential.response.clientDataJSON,
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
resultEl.closest("form").submit()
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
}
|
||||
|
||||
// Deprecated naming with underscores - kept for support with Ory Elements v0
|
||||
window.__oryWebAuthnLogin = __oryWebAuthnLogin
|
||||
window.__oryWebAuthnRegistration = __oryWebAuthnRegistration
|
||||
window.__oryPasskeySettingsRegistration = __oryPasskeySettingsRegistration
|
||||
window.__oryPasskeyLogin = __oryPasskeyLogin
|
||||
window.__oryPasskeyRegistration = __oryPasskeyRegistration
|
||||
window.__oryPasskeyLoginAutocompleteInit = __oryPasskeyLoginAutocompleteInit
|
||||
|
||||
// Current naming - use with Ory Elements v1
|
||||
window.oryWebAuthnLogin = __oryWebAuthnLogin
|
||||
window.oryWebAuthnRegistration = __oryWebAuthnRegistration
|
||||
window.oryPasskeySettingsRegistration = __oryPasskeySettingsRegistration
|
||||
window.oryPasskeyLogin = __oryPasskeyLogin
|
||||
window.oryPasskeyRegistration = __oryPasskeyRegistration
|
||||
window.oryPasskeyLoginAutocompleteInit = __oryPasskeyLoginAutocompleteInit
|
||||
|
||||
window.__oryWebAuthnInitialized = true
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("oryWebAuthnInitialized"),
|
||||
)
|
||||
})()
|
||||
87
x/webauthnx/nodes.go
Normal file
87
x/webauthnx/nodes.go
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package webauthnx
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"crypto/sha512"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/ory/kratos/x/webauthnx/js"
|
||||
|
||||
"github.com/ory/x/urlx"
|
||||
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/text"
|
||||
"github.com/ory/kratos/ui/node"
|
||||
)
|
||||
|
||||
func NewWebAuthnConnectionTrigger(options string) *node.Node {
|
||||
return node.NewInputField(node.WebAuthnRegisterTrigger, "", node.WebAuthnGroup,
|
||||
node.InputAttributeTypeButton, node.WithInputAttributes(func(a *node.InputAttributes) {
|
||||
//nolint:staticcheck
|
||||
a.OnClick = fmt.Sprintf("%s(%s)", js.WebAuthnTriggersWebAuthnRegistration, options)
|
||||
a.OnClickTrigger = js.WebAuthnTriggersWebAuthnRegistration
|
||||
a.FieldValue = options
|
||||
}))
|
||||
}
|
||||
|
||||
func NewWebAuthnScript(base *url.URL) *node.Node {
|
||||
src := urlx.AppendPaths(base, ScriptURL).String()
|
||||
integrity := sha512.Sum512(jsOnLoad)
|
||||
return node.NewScriptField(
|
||||
node.WebAuthnScript,
|
||||
src,
|
||||
node.WebAuthnGroup,
|
||||
fmt.Sprintf("sha512-%s", base64.StdEncoding.EncodeToString(integrity[:])),
|
||||
)
|
||||
}
|
||||
|
||||
func NewWebAuthnConnectionInput() *node.Node {
|
||||
return node.NewInputField(node.WebAuthnRegister, "", node.WebAuthnGroup,
|
||||
node.InputAttributeTypeHidden)
|
||||
}
|
||||
|
||||
func NewWebAuthnLoginTrigger(options string) *node.Node {
|
||||
return node.NewInputField(node.WebAuthnLoginTrigger, "", node.WebAuthnGroup,
|
||||
node.InputAttributeTypeButton, node.WithInputAttributes(func(a *node.InputAttributes) {
|
||||
//nolint:staticcheck
|
||||
a.OnClick = fmt.Sprintf("%s(%s)", js.WebAuthnTriggersWebAuthnLogin, options)
|
||||
a.FieldValue = options
|
||||
a.OnClickTrigger = js.WebAuthnTriggersWebAuthnLogin
|
||||
}))
|
||||
}
|
||||
|
||||
func NewWebAuthnLoginInput() *node.Node {
|
||||
return node.NewInputField(node.WebAuthnLogin, "", node.WebAuthnGroup,
|
||||
node.InputAttributeTypeHidden)
|
||||
}
|
||||
|
||||
func NewWebAuthnConnectionName() *node.Node {
|
||||
return node.NewInputField(node.WebAuthnRegisterDisplayName, "", node.WebAuthnGroup, node.InputAttributeTypeText).
|
||||
WithMetaLabel(text.NewInfoSelfServiceRegisterWebAuthnDisplayName())
|
||||
}
|
||||
|
||||
func NewWebAuthnUnlink(c *identity.CredentialWebAuthn, opts ...node.InputAttributesModifier) *node.Node {
|
||||
return node.NewInputField(
|
||||
node.WebAuthnRemove,
|
||||
fmt.Sprintf("%x", c.ID),
|
||||
node.WebAuthnGroup,
|
||||
node.InputAttributeTypeSubmit,
|
||||
opts...,
|
||||
).WithMetaLabel(text.NewInfoSelfServiceRemoveWebAuthn(cmp.Or(c.DisplayName, "unnamed"), c.AddedAt))
|
||||
}
|
||||
|
||||
func NewPasskeyUnlink(c *identity.CredentialWebAuthn, opts ...node.InputAttributesModifier) *node.Node {
|
||||
return node.NewInputField(
|
||||
node.PasskeyRemove,
|
||||
fmt.Sprintf("%x", c.ID),
|
||||
node.PasskeyGroup,
|
||||
node.InputAttributeTypeSubmit,
|
||||
opts...,
|
||||
).WithMetaLabel(text.NewInfoSelfServiceRemovePasskey(cmp.Or(c.DisplayName, "unnamed"), c.AddedAt))
|
||||
}
|
||||
66
x/webauthnx/signcount.go
Normal file
66
x/webauthnx/signcount.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// Copyright © 2025 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package webauthnx
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/x/logrusx"
|
||||
)
|
||||
|
||||
// SignCountDependencies is the minimal set of registry dependencies required to
|
||||
// persist an updated WebAuthn signature counter after a login.
|
||||
type SignCountDependencies interface {
|
||||
logrusx.Provider
|
||||
identity.PrivilegedPoolProvider
|
||||
}
|
||||
|
||||
// UpdateSignCountAfterLogin writes the mutable authenticator state reported
|
||||
// during a successful login back onto the stored credential: the signature
|
||||
// counter, the clone warning, and the backup state.
|
||||
//
|
||||
// It implements the relying-party side of W3C WebAuthn signature-counter
|
||||
// handling (https://www.w3.org/TR/webauthn-2/#sctn-sign-counter). When the
|
||||
// go-webauthn library detects that the counter did not advance it sets
|
||||
// CloneWarning, which we persist on the credential but deliberately do NOT act
|
||||
// on to block the login:
|
||||
//
|
||||
// - Synced passkeys (Apple iCloud, Google) legitimately always report a
|
||||
// counter of 0, so a hard "reject if not increasing" rule would lock out
|
||||
// real users.
|
||||
// - The user already proved possession of the authenticator, so a failure to
|
||||
// persist the counter must not turn a successful login into an error.
|
||||
//
|
||||
// The clone warning is durably stored on the credential and is inspectable via
|
||||
// the admin identity API, so there is no separate security log event.
|
||||
//
|
||||
// The write goes through UpdateCredentialsConfig, which re-reads the stored
|
||||
// config under an exclusive row lock and applies UpdateFromLogin to the latest
|
||||
// committed value, so concurrent logins of the same credential serialize
|
||||
// instead of clobbering each other's counter. That lock lives in its own
|
||||
// transaction and is deliberately NOT enrolled in the login request: the
|
||||
// assertion has already succeeded, so a failed or slow counter write must never
|
||||
// roll back or block the login. Persistence is therefore best-effort — errors
|
||||
// are logged, not returned.
|
||||
func UpdateSignCountAfterLogin(
|
||||
ctx context.Context,
|
||||
d SignCountDependencies,
|
||||
i *identity.Identity,
|
||||
credentialsType identity.CredentialsType,
|
||||
validated *webauthn.Credential,
|
||||
) {
|
||||
err := d.PrivilegedIdentityPool().UpdateCredentialsConfig(ctx, i.ID, credentialsType,
|
||||
identity.UpdateConfig(func(conf *identity.CredentialsWebAuthnConfig) error {
|
||||
conf.Credentials.UpdateFromLogin(validated)
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
d.Logger().WithError(err).WithField("identity_id", i.ID).WithField("network_id", i.NID).
|
||||
Warn("Unable to persist updated WebAuthn signature counter. The login succeeded but clone detection state was not persisted.")
|
||||
}
|
||||
}
|
||||
47
x/webauthnx/user.go
Normal file
47
x/webauthnx/user.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package webauthnx
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
var _ webauthn.User = (*User)(nil)
|
||||
|
||||
type User struct {
|
||||
Name string
|
||||
ID []byte
|
||||
Credentials []webauthn.Credential
|
||||
Config *webauthn.Config
|
||||
}
|
||||
|
||||
func NewUser(id []byte, credentials []webauthn.Credential, config *webauthn.Config) *User {
|
||||
return &User{
|
||||
ID: id,
|
||||
Credentials: credentials,
|
||||
Config: config,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *User) WebAuthnID() []byte {
|
||||
return u.ID
|
||||
}
|
||||
|
||||
func (u *User) WebAuthnName() string {
|
||||
return cmp.Or(u.Name, u.Config.RPDisplayName)
|
||||
}
|
||||
|
||||
func (u *User) WebAuthnDisplayName() string {
|
||||
return cmp.Or(u.Name, u.Config.RPDisplayName)
|
||||
}
|
||||
|
||||
func (u *User) WebAuthnIcon() string {
|
||||
return "" // Icon option has been removed due to security considerations.
|
||||
}
|
||||
|
||||
func (u *User) WebAuthnCredentials() []webauthn.Credential {
|
||||
return u.Credentials
|
||||
}
|
||||
Reference in New Issue
Block a user