Skip to content
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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ ZITADEL_IDP_ID=
ZITADEL_KEY=
ZITADEL_DOMAIN=https://auth.nais.io
ZITADEL_ORGANIZATION_ID=
# Enable nais.io user sync/Console login; ZITADEL_KEY must have user read access in this organization.
ZITADEL_NAIS_ORGANIZATION_ID=

AIVEN_PROJECTS='{"dev": {"id":"nav-dev","vpc":"somevpc","endpoint_id":"some_endpoint_id"}}'

Expand Down
6 changes: 6 additions & 0 deletions charts/nais-api/Feature.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,12 @@ values:
computed:
template: "{{ .Env.zitadel_organization_id | quote }}"

zitadel.naisOrganizationID:
displayName: Nais Zitadel organization ID
description: Enables syncing active nais.io users as viewers and allows nais.io Console login
computed:
template: '{{ .Env.zitadel_nais_organization_id | default "" | quote }}'

aiven.projects:
displayName: Aiven projects
description: |
Expand Down
6 changes: 6 additions & 0 deletions charts/nais-api/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ spec:
- name: OAUTH_FRONTEND_URL
value: "https://{{ .Values.host }}"
- name: OAUTH_ADDITIONAL_SCOPES
{{- if .Values.zitadel.naisOrganizationID }}
value: "urn:zitadel:iam:user:resourceowner"
{{- else }}
value: "urn:zitadel:iam:org:id:{{ .Values.zitadel.organizationID }}"
{{- end }}
- name: LISTEN_ADDRESS
value: ":3000"
- name: INTERNAL_LISTEN_ADDRESS
Expand Down Expand Up @@ -98,6 +102,8 @@ spec:
value: {{ .Values.zitadel.domain | quote }}
- name: ZITADEL_ORGANIZATION_ID
value: {{ .Values.zitadel.organizationID | quote }}
- name: ZITADEL_NAIS_ORGANIZATION_ID
value: {{ .Values.zitadel.naisOrganizationID | quote }}
- name: CLOUD_SQL_INSTANCE
value: {{ .Values.database.instance | quote }}
- name: NAIS_AUDIT_LOG_PROJECT_ID
Expand Down
1 change: 1 addition & 0 deletions charts/nais-api/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ zitadel:
idpID:
key:
organizationID:
naisOrganizationID: "" # Enables nais.io user sync and Console login when configured

aiven:
projects: ""
Expand Down
53 changes: 53 additions & 0 deletions docs/nais_io_users.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# nais.io users in tenant APIs

Set `ZITADEL_NAIS_ORGANIZATION_ID` (Helm: `zitadel.naisOrganizationID`) to the
nais.io organization ID to enable user sync and Console login. An empty value preserves
tenant-only login. Fasit supplies the value through `zitadel_nais_organization_id`.

The machine user configured by `ZITADEL_KEY` needs read access to users in the
nais.io organization, in addition to its existing tenant user-management access.
Terraform must also grant the nais.io organization access to each tenant's
Console project. These permissions must be deployed before enabling the setting.
The API also accepts CLI JWTs from the configured nais.io organization. Removing
the CLI's login restriction and adding tenant selection are handled separately.

## User identity

Email addresses are globally unique across tenants and Zitadel organizations:
the same email identifies the same user. Both user sources use the same
create/update logic, and Console login looks up both tenant and nais.io users by
email. The model does not distinguish separate identities with the same email.

The existing `external_id` field remains populated for compatibility with the
current tenant CLI and to preserve users when their email changes. It is not
used to distinguish nais.io users from tenant users during Console login.

## User lifecycle and permissions

- The API imports active human users from the configured Zitadel organization.
Users are not copied into the tenant's Zitadel organization or modified there.
- New nais.io users have the existing authenticated-user read access (Viewer),
without the tenant default `Team creator` role, admin access or team membership.
- Both user snapshots are fetched before database changes. Failure to fetch any
page aborts synchronization without deleting database users.
- Users absent from the next complete snapshot, including inactive, locked and
deleted Zitadel users, are deleted from the API database. Their sessions are
deleted by the database's foreign-key cascade.
- Changes are applied on the existing usersync schedule (every 15 minutes).
First-time users must exist in Zitadel and be synchronized before API login.

Zitadel is the source of truth for nais.io users. Suspending a user in Google
Workspace alone is not sufficient: deactivation must also reach Zitadel.

Console login no longer pins the flow to the tenant organization when nais.io
login is enabled. Users must still authenticate through the configured OIDC
provider and exist in the local API database.

CLI JWT authentication uses the same validation and user lookup for tenant and
nais.io users: the signature, issuer, audience and expiry must be valid, the
resource-owner organization must be either the tenant organization or the
configured nais.io organization, and the subject must match a synced user's
`external_id`. With no nais.io organization configured, only the tenant
organization is accepted. No token-format change is needed for this support.
Moving CLI authentication to email lookup later would require including email in
access tokens for both tenant and nais.io users.
136 changes: 136 additions & 0 deletions internal/auth/authn/handler_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
//go:build integration_test

package authn

import (
"context"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/coreos/go-oidc/v3/oidc"
"github.com/google/uuid"
"github.com/nais/api/internal/database"
"github.com/nais/api/internal/session"
"github.com/nais/api/internal/user"
"github.com/nais/api/internal/usersync/usersyncsql"
logrustest "github.com/sirupsen/logrus/hooks/test"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"golang.org/x/oauth2"
)

// callbackOAuth verifies the claims using the normal OIDC verifier with a test
// key set. Only signature verification is replaced; claims are still decoded by
// go-oidc, including its otherwise private IDToken claims field.
type callbackOAuth struct {
zitadel bool
subject string
email string
}

func (c callbackOAuth) IsZitadel() bool { return c.zitadel }

func (c callbackOAuth) AuthCodeURL(string, ...oauth2.AuthCodeOption) string { return "/login" }

func (c callbackOAuth) Exchange(context.Context, string, ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
return (&oauth2.Token{}).WithExtra(map[string]any{IDTokenKey: "test-token"}), nil
}

func (c callbackOAuth) Verify(ctx context.Context, _ string) (*oidc.IDToken, error) {
payload := fmt.Sprintf(`{"iss":"https://auth.nais.io","aud":"console","sub":%q,"email":%q,"exp":%d}`, c.subject, c.email, time.Now().Add(time.Hour).Unix())
raw := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256"}`)) + "." + base64.RawURLEncoding.EncodeToString([]byte(payload)) + ".c2ln"
return oidc.NewVerifier("https://auth.nais.io", callbackKeySet{payload: []byte(payload)}, &oidc.Config{ClientID: "console"}).Verify(ctx, raw)
}

type callbackKeySet struct{ payload []byte }

func (k callbackKeySet) VerifySignature(context.Context, string) ([]byte, error) {
return k.payload, nil
}

func TestCallbackUsesEmail(t *testing.T) {
ctx := t.Context()
log, _ := logrustest.NewNullLogger()
container, err := postgres.Run(ctx, "docker.io/postgres:16-alpine",
postgres.WithDatabase("test"), postgres.WithUsername("test"), postgres.WithPassword("test"),
postgres.BasicWaitStrategies())
if err != nil {
t.Fatal(err)
}
testcontainers.CleanupContainer(t, container)
dsn, err := container.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatal(err)
}
pool, err := database.NewPool(ctx, dsn, log, true)
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
q := usersyncsql.New(pool)
u, err := q.Create(ctx, usersyncsql.CreateParams{Name: "Nais viewer", Email: "viewer@nais.io", ExternalID: "nais-subject"})
if err != nil {
t.Fatal(err)
}
tenant, err := q.Create(ctx, usersyncsql.CreateParams{Name: "Tenant viewer", Email: "viewer@nav.no", ExternalID: "tenant-subject"})
if err != nil {
t.Fatal(err)
}

for _, tc := range []struct {
name string
config callbackOAuth
wantUserID uuid.UUID
}{
{name: "nais.io user", config: callbackOAuth{zitadel: true, subject: u.ExternalID, email: u.Email}, wantUserID: u.ID},
{name: "tenant user", config: callbackOAuth{zitadel: true, subject: tenant.ExternalID, email: tenant.Email}, wantUserID: tenant.ID},
{name: "nais.io user matched by email regardless of subject", config: callbackOAuth{zitadel: true, subject: "new-nais-subject", email: u.Email}, wantUserID: u.ID},
{name: "tenant user matched by email regardless of subject", config: callbackOAuth{zitadel: true, subject: "new-tenant-subject", email: tenant.Email}, wantUserID: tenant.ID},
{name: "known nais.io subject with unknown email", config: callbackOAuth{zitadel: true, subject: u.ExternalID, email: "unknown@nais.io"}},
{name: "known tenant subject with unknown email", config: callbackOAuth{zitadel: true, subject: tenant.ExternalID, email: "unknown@nav.no"}},
{name: "unsynced user", config: callbackOAuth{zitadel: true, subject: "unsynced", email: "unsynced@nais.io"}},
{name: "non-Zitadel login still uses email", config: callbackOAuth{subject: "google-subject", email: tenant.Email}, wantUserID: tenant.ID},
} {
t.Run(tc.name, func(t *testing.T) {
ctx := database.NewLoaderContext(t.Context(), pool)
ctx = user.NewLoaderContext(ctx, pool)
ctx = session.NewLoaderContext(ctx, pool)
req := httptest.NewRequest(http.MethodGet, "/oauth2/callback?code=code&state=state", nil).WithContext(ctx)
req.AddCookie(&http.Cookie{Name: OAuthStateCookie, Value: "state"})
rec := httptest.NewRecorder()
New(tc.config, log).Callback(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("expected redirect, got %d: %s", rec.Code, rec.Body.String())
}
if tc.wantUserID == uuid.Nil {
if got := rec.Header().Get("Location"); got != "/?error=unknown-user" {
t.Fatalf("expected unknown user, got %q", got)
}
for _, cookie := range rec.Result().Cookies() {
if cookie.Name == SessionCookieName {
t.Fatal("unexpected session for an unknown identity")
}
}
return
}
if got := rec.Header().Get("Location"); got != "/" {
t.Fatalf("expected successful login, got %q", got)
}
for _, cookie := range rec.Result().Cookies() {
if cookie.Name != SessionCookieName {
continue
}
sess, err := session.Get(ctx, uuid.MustParse(cookie.Value))
if err != nil || sess.UserID != tc.wantUserID {
t.Fatalf("session belongs to wrong identity: %v, %v", sess, err)
}
return
}
t.Fatal("missing session cookie")
})
}
}
51 changes: 33 additions & 18 deletions internal/auth/middleware/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"slices"
"time"

"github.com/lestrrat-go/httprc/v3"
Expand All @@ -20,13 +21,13 @@ import (
const acceptableClockSkew = 3 * time.Second

type jwtAuth struct {
issuer string
audience string
zitadelOrgID string
jwksURL string
jwksCache *jwk.Cache
log logrus.FieldLogger
now clock
issuer string
audience string
zitadelOrgIDs []string
jwksURL string
jwksCache *jwk.Cache
log logrus.FieldLogger
now clock
}

type clock func() time.Time
Expand Down Expand Up @@ -69,7 +70,16 @@ func (j *jwtAuth) validate(ctx context.Context, token string) (jwt.Token, error)
jwt.WithAcceptableSkew(acceptableClockSkew),
jwt.WithIssuer(j.issuer),
jwt.WithAudience(j.audience),
jwt.WithClaimValue("urn:zitadel:iam:user:resourceowner:id", j.zitadelOrgID),
jwt.WithValidator(jwt.ValidatorFunc(func(_ context.Context, token jwt.Token) error {
var orgID string
if err := token.Get("urn:zitadel:iam:user:resourceowner:id", &orgID); err != nil {
return fmt.Errorf("get token organization: %w", err)
}
if orgID == "" || !slices.Contains(j.zitadelOrgIDs, orgID) {
return errors.New("token organization is not allowed")
}
return nil
})),
jwt.WithClock(j.now),
)
}
Expand Down Expand Up @@ -116,15 +126,20 @@ func (j *jwtAuth) handler(next http.Handler) http.Handler {
return http.HandlerFunc(fn)
}

func JWTAuthentication(ctx context.Context, issuer, audience, zitadelOrgID string, log logrus.FieldLogger) (func(next http.Handler) http.Handler, error) {
func JWTAuthentication(ctx context.Context, issuer, audience string, zitadelOrgIDs []string, log logrus.FieldLogger) (func(next http.Handler) http.Handler, error) {
if issuer == "" {
return nil, errors.New("issuer is required")
}
if audience == "" {
return nil, errors.New("audience is required")
}
if zitadelOrgID == "" {
return nil, errors.New("zitadelOrgID is required")
if len(zitadelOrgIDs) == 0 {
return nil, errors.New("at least one zitadel organization ID is required")
}
for _, orgID := range zitadelOrgIDs {
if orgID == "" {
return nil, errors.New("zitadel organization ID cannot be empty")
}
}

client, err := client.Discover(ctx, issuer, http.DefaultClient)
Expand All @@ -143,13 +158,13 @@ func JWTAuthentication(ctx context.Context, issuer, audience, zitadelOrgID strin
}

auth := jwtAuth{
jwksURL: client.JwksURI,
jwksCache: cache,
issuer: issuer,
audience: audience,
zitadelOrgID: zitadelOrgID,
log: log,
now: time.Now,
jwksURL: client.JwksURI,
jwksCache: cache,
issuer: issuer,
audience: audience,
zitadelOrgIDs: zitadelOrgIDs,
log: log,
now: time.Now,
}

return auth.handler, nil
Expand Down
Loading
Loading