Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OIDC: Expire machines/nodes after token expiry #1067

Merged
merged 5 commits into from
Jan 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- Fix duplicate nodes due to incorrect implementation of the protocol [#1058](https://github.com/juanfont/headscale/pull/1058)
- Report if a machine is online in CLI more accurately [#1062](https://github.com/juanfont/headscale/pull/1062)
- Added config option for custom DNS records [#1035](https://github.com/juanfont/headscale/pull/1035)
- Expire nodes based on OIDC token expiry [#1067](https://github.com/juanfont/headscale/pull/1067)

## 0.17.1 (2022-12-05)

Expand Down
57 changes: 57 additions & 0 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,15 @@ func (h *Headscale) expireEphemeralNodes(milliSeconds int64) {
}
}

// expireExpiredMachines expires machines that have an explicit expiry set
// after that expiry time has passed.
func (h *Headscale) expireExpiredMachines(milliSeconds int64) {
ticker := time.NewTicker(time.Duration(milliSeconds) * time.Millisecond)
for range ticker.C {
h.expireExpiredMachinesWorker()
}
}

func (h *Headscale) failoverSubnetRoutes(milliSeconds int64) {
ticker := time.NewTicker(time.Duration(milliSeconds) * time.Millisecond)
for range ticker.C {
Expand Down Expand Up @@ -273,6 +282,53 @@ func (h *Headscale) expireEphemeralNodesWorker() {
}
}

func (h *Headscale) expireExpiredMachinesWorker() {
namespaces, err := h.ListNamespaces()
if err != nil {
log.Error().Err(err).Msg("Error listing namespaces")

return
}

for _, namespace := range namespaces {
machines, err := h.ListMachinesInNamespace(namespace.Name)
if err != nil {
log.Error().
Err(err).
Str("namespace", namespace.Name).
Msg("Error listing machines in namespace")

return
}

expiredFound := false
for index, machine := range machines {
if machine.isExpired() &&
machine.Expiry.After(h.getLastStateChange(namespace)) {
expiredFound = true

err := h.ExpireMachine(&machines[index])
if err != nil {
log.Error().
Err(err).
Str("machine", machine.Hostname).
Str("name", machine.GivenName).
Msg("🤮 Cannot expire machine")
} else {
log.Info().
Str("machine", machine.Hostname).
Str("name", machine.GivenName).
Msg("Machine successfully expired")
}
}
}

if expiredFound {
h.setLastStateChangeToNow()
}
}
}

func (h *Headscale) grpcAuthenticationInterceptor(ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
Expand Down Expand Up @@ -494,6 +550,7 @@ func (h *Headscale) Serve() error {
}

go h.expireEphemeralNodes(updateInterval)
go h.expireExpiredMachines(updateInterval)

go h.failoverSubnetRoutes(updateInterval)

Expand Down
13 changes: 12 additions & 1 deletion cmd/headscale/cli/mockoidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@ const (
errMockOidcClientIDNotDefined = Error("MOCKOIDC_CLIENT_ID not defined")
errMockOidcClientSecretNotDefined = Error("MOCKOIDC_CLIENT_SECRET not defined")
errMockOidcPortNotDefined = Error("MOCKOIDC_PORT not defined")
accessTTL = 10 * time.Minute
refreshTTL = 60 * time.Minute
)

var accessTTL = 2 * time.Minute

func init() {
rootCmd.AddCommand(mockOidcCmd)
}
Expand Down Expand Up @@ -54,6 +55,16 @@ func mockOIDC() error {
if portStr == "" {
return errMockOidcPortNotDefined
}
accessTTLOverride := os.Getenv("MOCKOIDC_ACCESS_TTL")
if accessTTLOverride != "" {
newTTL, err := time.ParseDuration(accessTTLOverride)
if err != nil {
return err
}
accessTTL = newTTL
}

log.Info().Msgf("Access token TTL: %s", accessTTL)

port, err := strconv.Atoi(portStr)
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ please ask on [Discord](https://discord.gg/c84AZQhmpx) instead of opening an Iss
- [Running headscale on Linux](running-headscale-linux.md)
- [Control headscale remotely](remote-cli.md)
- [Using a Windows client with headscale](windows-client.md)
- [Configuring OIDC](oidc.md)

### References

Expand Down
137 changes: 137 additions & 0 deletions docs/oidc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Configuring Headscale to use OIDC authentication

In order to authenticate users through a centralized solution one must enable the OIDC integration.

Known limitations:

- No dynamic ACL support
- OIDC groups cannot be used in ACLs

## Basic configuration

In your `config.yaml`, customize this to your liking:

```yaml
oidc:
# Block further startup until the OIDC provider is healthy and available
only_start_if_oidc_is_available: true
# Specified by your OIDC provider
issuer: "https://your-oidc.issuer.com/path"
# Specified/generated by your OIDC provider
client_id: "your-oidc-client-id"
client_secret: "your-oidc-client-secret"

# Customize the scopes used in the OIDC flow, defaults to "openid", "profile" and "email" and add custom query
# parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email".
scope: ["openid", "profile", "email", "custom"]
# Optional: Passed on to the browser login request – used to tweak behaviour for the OIDC provider
extra_params:
domain_hint: example.com

# Optional: List allowed principal domains and/or users. If an authenticated user's domain is not in this list,
# the authentication request will be rejected.
allowed_domains:
- example.com
# Optional. Note that groups from Keycloak have a leading '/'.
allowed_groups:
- /headscale
# Optional.
allowed_users:
- [email protected]

# If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed.
# This will transform `[email protected]` to the namespace `first-name.last-name`
# If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following
# namespace: `first-name.last-name.example.com`
strip_email_domain: true
```

## Azure AD example

In order to integrate Headscale with Azure Active Directory, we'll need to provision an App Registration with the correct scopes and redirect URI. Here with Terraform:

```hcl
resource "azuread_application" "headscale" {
display_name = "Headscale"

sign_in_audience = "AzureADMyOrg"
fallback_public_client_enabled = false

required_resource_access {
// Microsoft Graph
resource_app_id = "00000003-0000-0000-c000-000000000000"

resource_access {
// scope: profile
id = "14dad69e-099b-42c9-810b-d002981feec1"
type = "Scope"
}
resource_access {
// scope: openid
id = "37f7f235-527c-4136-accd-4a02d197296e"
type = "Scope"
}
resource_access {
// scope: email
id = "64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0"
type = "Scope"
}
}
web {
# Points at your running Headscale instance
redirect_uris = ["https://headscale.example.com/oidc/callback"]

implicit_grant {
access_token_issuance_enabled = false
id_token_issuance_enabled = true
}
}

group_membership_claims = ["SecurityGroup"]
optional_claims {
# Expose group memberships
id_token {
name = "groups"
}
}
}

resource "azuread_application_password" "headscale-application-secret" {
display_name = "Headscale Server"
application_object_id = azuread_application.headscale.object_id
}

resource "azuread_service_principal" "headscale" {
application_id = azuread_application.headscale.application_id
}

resource "azuread_service_principal_password" "headscale" {
service_principal_id = azuread_service_principal.headscale.id
end_date_relative = "44640h"
}

output "headscale_client_id" {
value = azuread_application.headscale.application_id
}

output "headscale_client_secret" {
value = azuread_application_password.headscale-application-secret.value
}
```

And in your Headscale `config.yaml`:

```yaml
oidc:
issuer: "https://login.microsoftonline.com/<tenant-UUID>/v2.0"
client_id: "<client-id-from-terraform>"
client_secret: "<client-secret-from-terraform>"

# Optional: add "groups"
scope: ["openid", "profile", "email"]
extra_params:
# Use your own domain, associated with Azure AD
domain_hint: example.com
# Optional: Force the Azure AD account picker
prompt: select_account
```
1 change: 1 addition & 0 deletions grpcv1.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ func (api headscaleV1APIServer) RegisterMachine(
machine, err := api.h.RegisterMachineFromAuthCallback(
request.GetKey(),
request.GetNamespace(),
nil,
RegisterMethodCLI,
)
if err != nil {
Expand Down
Loading