mirror of
https://github.com/juanfont/headscale.git
synced 2025-08-16 04:27:45 +00:00
Compare commits
7 Commits
v0.26.0-be
...
doc/0.26.1
Author | SHA1 | Date | |
---|---|---|---|
![]() |
00a5cce7fd | ||
![]() |
4d89030701 | ||
![]() |
474ea236d0 | ||
![]() |
2dc2f3b3f0 | ||
![]() |
d7a503a34e | ||
![]() |
62b489dc68 | ||
![]() |
8c7e650616 |
24
CHANGELOG.md
24
CHANGELOG.md
@@ -1,6 +1,14 @@
|
|||||||
# CHANGELOG
|
# CHANGELOG
|
||||||
|
|
||||||
## Next
|
## 0.26.1 (2025-06-06)
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- Ensure nodes are matching both node key and machine key
|
||||||
|
when connecting.
|
||||||
|
[#2642](https://github.com/juanfont/headscale/pull/2642)
|
||||||
|
|
||||||
|
## 0.26.0 (2025-05-14)
|
||||||
|
|
||||||
### BREAKING
|
### BREAKING
|
||||||
|
|
||||||
@@ -69,17 +77,17 @@ new policy code passes all of our tests.
|
|||||||
<summary>Migration notes when the policy is stored in the database.</summary>
|
<summary>Migration notes when the policy is stored in the database.</summary>
|
||||||
|
|
||||||
This section **only** applies if the policy is stored in the database and
|
This section **only** applies if the policy is stored in the database and
|
||||||
Headscale 0.26 doesn't start due to a policy error (`failed to load ACL
|
Headscale 0.26 doesn't start due to a policy error
|
||||||
policy`).
|
(`failed to load ACL policy`).
|
||||||
|
|
||||||
* Start Headscale 0.26 with the environment variable `HEADSCALE_POLICY_V1=1`
|
- Start Headscale 0.26 with the environment variable `HEADSCALE_POLICY_V1=1`
|
||||||
set. You can check that Headscale picked up the environment variable by
|
set. You can check that Headscale picked up the environment variable by
|
||||||
observing this message during startup: `Using policy manager version: 1`
|
observing this message during startup: `Using policy manager version: 1`
|
||||||
* Dump the policy to a file: `headscale policy get > policy.json`
|
- Dump the policy to a file: `headscale policy get > policy.json`
|
||||||
* Edit `policy.json` and migrate to policy V2. Use the command
|
- Edit `policy.json` and migrate to policy V2. Use the command
|
||||||
`headscale policy check --file policy.json` to check for policy errors.
|
`headscale policy check --file policy.json` to check for policy errors.
|
||||||
* Load the modified policy: `headscale policy set --file policy.json`
|
- Load the modified policy: `headscale policy set --file policy.json`
|
||||||
* Restart Headscale **without** the environment variable `HEADSCALE_POLICY_V1`.
|
- Restart Headscale **without** the environment variable `HEADSCALE_POLICY_V1`.
|
||||||
Headscale should now print the message `Using policy manager version: 2` and
|
Headscale should now print the message `Using policy manager version: 2` and
|
||||||
startup successfully.
|
startup successfully.
|
||||||
|
|
||||||
|
@@ -112,7 +112,7 @@ var listPreAuthKeys = &cobra.Command{
|
|||||||
aclTags = strings.TrimLeft(aclTags, ",")
|
aclTags = strings.TrimLeft(aclTags, ",")
|
||||||
|
|
||||||
tableData = append(tableData, []string{
|
tableData = append(tableData, []string{
|
||||||
strconv.FormatUint(key.GetId(), 64),
|
strconv.FormatUint(key.GetId(), 10),
|
||||||
key.GetKey(),
|
key.GetKey(),
|
||||||
strconv.FormatBool(key.GetReusable()),
|
strconv.FormatBool(key.GetReusable()),
|
||||||
strconv.FormatBool(key.GetEphemeral()),
|
strconv.FormatBool(key.GetEphemeral()),
|
||||||
|
@@ -375,19 +375,6 @@ unix_socket_permission: "0770"
|
|||||||
# # - plain: Use plain code verifier
|
# # - plain: Use plain code verifier
|
||||||
# # - S256: Use SHA256 hashed code verifier (default, recommended)
|
# # - S256: Use SHA256 hashed code verifier (default, recommended)
|
||||||
# method: S256
|
# method: S256
|
||||||
#
|
|
||||||
# # Map legacy users from pre-0.24.0 versions of headscale to the new OIDC users
|
|
||||||
# # by taking the username from the legacy user and matching it with the username
|
|
||||||
# # provided by the OIDC. This is useful when migrating from legacy users to OIDC
|
|
||||||
# # to force them using the unique identifier from the OIDC and to give them a
|
|
||||||
# # proper display name and picture if available.
|
|
||||||
# # Note that this will only work if the username from the legacy user is the same
|
|
||||||
# # and there is a possibility for account takeover should a username have changed
|
|
||||||
# # with the provider.
|
|
||||||
# # When this feature is disabled, it will cause all new logins to be created as new users.
|
|
||||||
# # Note this option will be removed in the future and should be set to false
|
|
||||||
# # on all new installations, or when all users have logged in with OIDC once.
|
|
||||||
# map_legacy_users: false
|
|
||||||
|
|
||||||
# Logtail configuration
|
# Logtail configuration
|
||||||
# Logtail is Tailscales logging and auditing infrastructure, it allows the control panel
|
# Logtail is Tailscales logging and auditing infrastructure, it allows the control panel
|
||||||
|
@@ -112,11 +112,11 @@ docker exec -it headscale \
|
|||||||
|
|
||||||
### Register a machine using a pre authenticated key
|
### Register a machine using a pre authenticated key
|
||||||
|
|
||||||
Generate a key using the command line:
|
Generate a key using the command line for the user with ID 1:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker exec -it headscale \
|
docker exec -it headscale \
|
||||||
headscale preauthkeys create --user myfirstuser --reusable --expiration 24h
|
headscale preauthkeys create --user 1 --reusable --expiration 24h
|
||||||
```
|
```
|
||||||
|
|
||||||
This will return a pre-authenticated key that can be used to connect a node to headscale with the `tailscale up` command:
|
This will return a pre-authenticated key that can be used to connect a node to headscale with the `tailscale up` command:
|
||||||
|
@@ -117,14 +117,14 @@ headscale instance. By default, the key is valid for one hour and can only be us
|
|||||||
=== "Native"
|
=== "Native"
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
headscale preauthkeys create --user <USER>
|
headscale preauthkeys create --user <USER_ID>
|
||||||
```
|
```
|
||||||
|
|
||||||
=== "Container"
|
=== "Container"
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
docker exec -it headscale \
|
docker exec -it headscale \
|
||||||
headscale preauthkeys create --user <USER>
|
headscale preauthkeys create --user <USER_ID>
|
||||||
```
|
```
|
||||||
|
|
||||||
The command returns the preauthkey on success which is used to connect a node to the headscale instance via the
|
The command returns the preauthkey on success which is used to connect a node to the headscale instance via the
|
||||||
|
@@ -695,6 +695,29 @@ AND auth_key_id NOT IN (
|
|||||||
},
|
},
|
||||||
Rollback: func(db *gorm.DB) error { return nil },
|
Rollback: func(db *gorm.DB) error { return nil },
|
||||||
},
|
},
|
||||||
|
// Fix the provider identifier for users that have a double slash in the
|
||||||
|
// provider identifier.
|
||||||
|
{
|
||||||
|
ID: "202505141324",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
users, err := ListUsers(tx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing users: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, user := range users {
|
||||||
|
user.ProviderIdentifier.String = types.CleanIdentifier(user.ProviderIdentifier.String)
|
||||||
|
|
||||||
|
err := tx.Save(user).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("saving user: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Rollback: func(db *gorm.DB) error { return nil },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -100,6 +100,10 @@ func (h *Headscale) NoiseUpgradeHandler(
|
|||||||
|
|
||||||
router.HandleFunc("/machine/register", noiseServer.NoiseRegistrationHandler).
|
router.HandleFunc("/machine/register", noiseServer.NoiseRegistrationHandler).
|
||||||
Methods(http.MethodPost)
|
Methods(http.MethodPost)
|
||||||
|
|
||||||
|
// Endpoints outside of the register endpoint must use getAndValidateNode to
|
||||||
|
// get the node to ensure that the MachineKey matches the Node setting up the
|
||||||
|
// connection.
|
||||||
router.HandleFunc("/machine/map", noiseServer.NoisePollNetMapHandler)
|
router.HandleFunc("/machine/map", noiseServer.NoisePollNetMapHandler)
|
||||||
|
|
||||||
noiseServer.httpBaseConfig = &http.Server{
|
noiseServer.httpBaseConfig = &http.Server{
|
||||||
@@ -209,18 +213,14 @@ func (ns *noiseServer) NoisePollNetMapHandler(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ns.nodeKey = mapRequest.NodeKey
|
node, err := ns.getAndValidateNode(mapRequest)
|
||||||
|
|
||||||
node, err := ns.headscale.db.GetNodeByNodeKey(mapRequest.NodeKey)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
httpError(writer, NewHTTPError(http.StatusNotFound, "node not found", nil))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
httpError(writer, err)
|
httpError(writer, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ns.nodeKey = node.NodeKey
|
||||||
|
|
||||||
sess := ns.headscale.newMapSession(req.Context(), mapRequest, writer, node)
|
sess := ns.headscale.newMapSession(req.Context(), mapRequest, writer, node)
|
||||||
sess.tracef("a node sending a MapRequest with Noise protocol")
|
sess.tracef("a node sending a MapRequest with Noise protocol")
|
||||||
if !sess.isStreaming() {
|
if !sess.isStreaming() {
|
||||||
@@ -266,8 +266,8 @@ func (ns *noiseServer) NoiseRegistrationHandler(
|
|||||||
Error: httpErr.Msg,
|
Error: httpErr.Msg,
|
||||||
}
|
}
|
||||||
return ®Req, resp
|
return ®Req, resp
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ®Req, regErr(err)
|
return ®Req, regErr(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,3 +289,22 @@ func (ns *noiseServer) NoiseRegistrationHandler(
|
|||||||
writer.WriteHeader(http.StatusOK)
|
writer.WriteHeader(http.StatusOK)
|
||||||
writer.Write(respBody)
|
writer.Write(respBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getAndValidateNode retrieves the node from the database using the NodeKey
|
||||||
|
// and validates that it matches the MachineKey from the Noise session.
|
||||||
|
func (ns *noiseServer) getAndValidateNode(mapRequest tailcfg.MapRequest) (*types.Node, error) {
|
||||||
|
node, err := ns.headscale.db.GetNodeByNodeKey(mapRequest.NodeKey)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, NewHTTPError(http.StatusNotFound, "node not found", nil)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that the MachineKey in the Noise session matches the one associated with the NodeKey.
|
||||||
|
if ns.machineKey != node.MachineKey {
|
||||||
|
return nil, NewHTTPError(http.StatusNotFound, "node key in request does not match the one associated with this machine key", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return node, nil
|
||||||
|
}
|
||||||
|
@@ -194,13 +194,110 @@ type OIDCClaims struct {
|
|||||||
Username string `json:"preferred_username,omitempty"`
|
Username string `json:"preferred_username,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Identifier returns a unique identifier string combining the Iss and Sub claims.
|
||||||
|
// The format depends on whether Iss is a URL or not:
|
||||||
|
// - For URLs: Joins the URL and sub path (e.g., "https://example.com/sub")
|
||||||
|
// - For non-URLs: Joins with a slash (e.g., "oidc/sub")
|
||||||
|
// - For empty Iss: Returns just "sub"
|
||||||
|
// - For empty Sub: Returns just the Issuer
|
||||||
|
// - For both empty: Returns empty string
|
||||||
|
//
|
||||||
|
// The result is cleaned using CleanIdentifier() to ensure consistent formatting.
|
||||||
func (c *OIDCClaims) Identifier() string {
|
func (c *OIDCClaims) Identifier() string {
|
||||||
if strings.HasPrefix(c.Iss, "http") {
|
// Handle empty components special cases
|
||||||
if i, err := url.JoinPath(c.Iss, c.Sub); err == nil {
|
if c.Iss == "" && c.Sub == "" {
|
||||||
return i
|
return ""
|
||||||
|
}
|
||||||
|
if c.Iss == "" {
|
||||||
|
return CleanIdentifier(c.Sub)
|
||||||
|
}
|
||||||
|
if c.Sub == "" {
|
||||||
|
return CleanIdentifier(c.Iss)
|
||||||
|
}
|
||||||
|
|
||||||
|
// We'll use the raw values and let CleanIdentifier handle all the whitespace
|
||||||
|
issuer := c.Iss
|
||||||
|
subject := c.Sub
|
||||||
|
|
||||||
|
var result string
|
||||||
|
// Try to parse as URL to handle URL joining correctly
|
||||||
|
if u, err := url.Parse(issuer); err == nil && u.Scheme != "" {
|
||||||
|
// For URLs, use proper URL path joining
|
||||||
|
if joined, err := url.JoinPath(issuer, subject); err == nil {
|
||||||
|
result = joined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return c.Iss + "/" + c.Sub
|
|
||||||
|
// If URL joining failed or issuer wasn't a URL, do simple string join
|
||||||
|
if result == "" {
|
||||||
|
// Default case: simple string joining with slash
|
||||||
|
issuer = strings.TrimSuffix(issuer, "/")
|
||||||
|
subject = strings.TrimPrefix(subject, "/")
|
||||||
|
result = issuer + "/" + subject
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean the result and return it
|
||||||
|
return CleanIdentifier(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanIdentifier cleans a potentially malformed identifier by removing double slashes
|
||||||
|
// while preserving protocol specifications like http://. This function will:
|
||||||
|
// - Trim all whitespace from the beginning and end of the identifier
|
||||||
|
// - Remove whitespace within path segments
|
||||||
|
// - Preserve the scheme (http://, https://, etc.) for URLs
|
||||||
|
// - Remove any duplicate slashes in the path
|
||||||
|
// - Remove empty path segments
|
||||||
|
// - For non-URL identifiers, it joins non-empty segments with a single slash
|
||||||
|
// - Returns empty string for identifiers with only slashes
|
||||||
|
// - Normalize URL schemes to lowercase
|
||||||
|
func CleanIdentifier(identifier string) string {
|
||||||
|
if identifier == "" {
|
||||||
|
return identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim leading/trailing whitespace
|
||||||
|
identifier = strings.TrimSpace(identifier)
|
||||||
|
|
||||||
|
// Handle URLs with schemes
|
||||||
|
u, err := url.Parse(identifier)
|
||||||
|
if err == nil && u.Scheme != "" {
|
||||||
|
// Clean path by removing empty segments and whitespace within segments
|
||||||
|
parts := strings.FieldsFunc(u.Path, func(c rune) bool { return c == '/' })
|
||||||
|
for i, part := range parts {
|
||||||
|
parts[i] = strings.TrimSpace(part)
|
||||||
|
}
|
||||||
|
// Remove empty parts after trimming
|
||||||
|
cleanParts := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
if part != "" {
|
||||||
|
cleanParts = append(cleanParts, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cleanParts) == 0 {
|
||||||
|
u.Path = ""
|
||||||
|
} else {
|
||||||
|
u.Path = "/" + strings.Join(cleanParts, "/")
|
||||||
|
}
|
||||||
|
// Ensure scheme is lowercase
|
||||||
|
u.Scheme = strings.ToLower(u.Scheme)
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle non-URL identifiers
|
||||||
|
parts := strings.FieldsFunc(identifier, func(c rune) bool { return c == '/' })
|
||||||
|
// Clean whitespace from each part
|
||||||
|
cleanParts := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
trimmed := strings.TrimSpace(part)
|
||||||
|
if trimmed != "" {
|
||||||
|
cleanParts = append(cleanParts, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cleanParts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(cleanParts, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
type OIDCUserInfo struct {
|
type OIDCUserInfo struct {
|
||||||
@@ -231,7 +328,13 @@ func (u *User) FromClaim(claims *OIDCClaims) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
u.ProviderIdentifier = sql.NullString{String: claims.Identifier(), Valid: true}
|
// Get provider identifier
|
||||||
|
identifier := claims.Identifier()
|
||||||
|
// Ensure provider identifier always has a leading slash for backward compatibility
|
||||||
|
if claims.Iss == "" && !strings.HasPrefix(identifier, "/") {
|
||||||
|
identifier = "/" + identifier
|
||||||
|
}
|
||||||
|
u.ProviderIdentifier = sql.NullString{String: identifier, Valid: true}
|
||||||
u.DisplayName = claims.Name
|
u.DisplayName = claims.Name
|
||||||
u.ProfilePicURL = claims.ProfilePictureURL
|
u.ProfilePicURL = claims.ProfilePictureURL
|
||||||
u.Provider = util.RegisterMethodOIDC
|
u.Provider = util.RegisterMethodOIDC
|
||||||
|
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
"github.com/juanfont/headscale/hscontrol/util"
|
"github.com/juanfont/headscale/hscontrol/util"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestUnmarshallOIDCClaims(t *testing.T) {
|
func TestUnmarshallOIDCClaims(t *testing.T) {
|
||||||
@@ -76,6 +77,218 @@ func TestUnmarshallOIDCClaims(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOIDCClaimsIdentifier(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
iss string
|
||||||
|
sub string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "standard URL with trailing slash",
|
||||||
|
iss: "https://oidc.example.com/",
|
||||||
|
sub: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
expected: "https://oidc.example.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "standard URL without trailing slash",
|
||||||
|
iss: "https://oidc.example.com",
|
||||||
|
sub: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
expected: "https://oidc.example.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "standard URL with uppercase protocol",
|
||||||
|
iss: "HTTPS://oidc.example.com/",
|
||||||
|
sub: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
expected: "https://oidc.example.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "standard URL with path and trailing slash",
|
||||||
|
iss: "https://login.microsoftonline.com/v2.0/",
|
||||||
|
sub: "I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
|
||||||
|
expected: "https://login.microsoftonline.com/v2.0/I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "standard URL with path without trailing slash",
|
||||||
|
iss: "https://login.microsoftonline.com/v2.0",
|
||||||
|
sub: "I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
|
||||||
|
expected: "https://login.microsoftonline.com/v2.0/I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-URL identifier with slash",
|
||||||
|
iss: "oidc",
|
||||||
|
sub: "sub",
|
||||||
|
expected: "oidc/sub",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-URL identifier with trailing slash",
|
||||||
|
iss: "oidc/",
|
||||||
|
sub: "sub",
|
||||||
|
expected: "oidc/sub",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subject with slash",
|
||||||
|
iss: "oidc/",
|
||||||
|
sub: "sub/",
|
||||||
|
expected: "oidc/sub",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace",
|
||||||
|
iss: " oidc/ ",
|
||||||
|
sub: " sub ",
|
||||||
|
expected: "oidc/sub",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "newline",
|
||||||
|
iss: "\noidc/\n",
|
||||||
|
sub: "\nsub\n",
|
||||||
|
expected: "oidc/sub",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tab",
|
||||||
|
iss: "\toidc/\t",
|
||||||
|
sub: "\tsub\t",
|
||||||
|
expected: "oidc/sub",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty issuer",
|
||||||
|
iss: "",
|
||||||
|
sub: "sub",
|
||||||
|
expected: "sub",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty subject",
|
||||||
|
iss: "https://oidc.example.com",
|
||||||
|
sub: "",
|
||||||
|
expected: "https://oidc.example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both empty",
|
||||||
|
iss: "",
|
||||||
|
sub: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with double slash",
|
||||||
|
iss: "https://login.microsoftonline.com//v2.0",
|
||||||
|
sub: "I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
|
||||||
|
expected: "https://login.microsoftonline.com/v2.0/I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "FTP URL protocol",
|
||||||
|
iss: "ftp://example.com/directory",
|
||||||
|
sub: "resource",
|
||||||
|
expected: "ftp://example.com/directory/resource",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
claims := OIDCClaims{
|
||||||
|
Iss: tt.iss,
|
||||||
|
Sub: tt.sub,
|
||||||
|
}
|
||||||
|
result := claims.Identifier()
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
if diff := cmp.Diff(tt.expected, result); diff != "" {
|
||||||
|
t.Errorf("Identifier() mismatch (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now clean the identifier and verify it's still the same
|
||||||
|
cleaned := CleanIdentifier(result)
|
||||||
|
|
||||||
|
// Double-check with cmp.Diff for better error messages
|
||||||
|
if diff := cmp.Diff(tt.expected, cleaned); diff != "" {
|
||||||
|
t.Errorf("CleanIdentifier(Identifier()) mismatch (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanIdentifier(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
identifier string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty identifier",
|
||||||
|
identifier: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple identifier",
|
||||||
|
identifier: "oidc/sub",
|
||||||
|
expected: "oidc/sub",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double slashes in the middle",
|
||||||
|
identifier: "oidc//sub",
|
||||||
|
expected: "oidc/sub",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trailing slash",
|
||||||
|
identifier: "oidc/sub/",
|
||||||
|
expected: "oidc/sub",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple double slashes",
|
||||||
|
identifier: "oidc//sub///id//",
|
||||||
|
expected: "oidc/sub/id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HTTP URL with proper scheme",
|
||||||
|
identifier: "http://example.com/path",
|
||||||
|
expected: "http://example.com/path",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HTTP URL with double slashes in path",
|
||||||
|
identifier: "http://example.com//path///resource",
|
||||||
|
expected: "http://example.com/path/resource",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HTTPS URL with empty segments",
|
||||||
|
identifier: "https://example.com///path//",
|
||||||
|
expected: "https://example.com/path",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL with double slashes in domain",
|
||||||
|
identifier: "https://login.microsoftonline.com//v2.0/I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
|
||||||
|
expected: "https://login.microsoftonline.com/v2.0/I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "FTP URL with double slashes",
|
||||||
|
identifier: "ftp://example.com//resource//",
|
||||||
|
expected: "ftp://example.com/resource",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Just slashes",
|
||||||
|
identifier: "///",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Leading slash without URL",
|
||||||
|
identifier: "/path//to///resource",
|
||||||
|
expected: "path/to/resource",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Non-standard protocol",
|
||||||
|
identifier: "ldap://example.org//path//to//resource",
|
||||||
|
expected: "ldap://example.org/path/to/resource",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := CleanIdentifier(tt.identifier)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
if diff := cmp.Diff(tt.expected, result); diff != "" {
|
||||||
|
t.Errorf("CleanIdentifier() mismatch (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOIDCClaimsJSONToUser(t *testing.T) {
|
func TestOIDCClaimsJSONToUser(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
@@ -107,7 +107,7 @@ extra:
|
|||||||
- icon: fontawesome/brands/discord
|
- icon: fontawesome/brands/discord
|
||||||
link: https://discord.gg/c84AZQhmpx
|
link: https://discord.gg/c84AZQhmpx
|
||||||
headscale:
|
headscale:
|
||||||
version: 0.25.0
|
version: 0.26.1
|
||||||
|
|
||||||
# Extensions
|
# Extensions
|
||||||
markdown_extensions:
|
markdown_extensions:
|
||||||
|
Reference in New Issue
Block a user