Skip to content

auth

napt.auth.credentials

Microsoft Graph authentication for NAPT.

Every command that talks to Intune (napt upload, napt promote apply, napt promote plan --reconcile/--check-drift) calls get_access_token once. The token comes from the first source that works:

Non-interactive (CI/CD): 1. EnvironmentCredential -- service principal via AZURE_CLIENT_ID, AZURE_TENANT_ID and either AZURE_CLIENT_SECRET or AZURE_CLIENT_CERTIFICATE_PATH.

Interactive (a person at a terminal): 2. The active tenant's session established earlier with napt auth login. Tokens are cached by MSAL in an OS-encrypted store (DPAPI on Windows, Keychain on macOS, libsecret on Linux) and refreshed silently; the browser or Windows broker is only opened by napt auth login itself, never by napt upload. Several tenants can be signed in at once; napt auth login --tenant-id switches the active one.

CI/CD through a login step: 3. AzureCliCredential -- an existing az login session signed in as a service principal, which is what OIDC login steps such as GitHub Actions azure/login leave behind. Recommended over client secrets when the CI platform supports it: no secret to store or rotate. Last in the chain so a developer's napt auth login always wins, and a session signed in as a person is refused rather than used, since its token belongs to the Azure CLI's own application.

napt auth login uses the authorization code flow with PKCE against a loopback redirect, or -- on Windows, when the MSAL broker runtime is installed -- the Web Account Manager (WAM) broker, which gives single sign-on with accounts known to Windows, honors device-based Conditional Access, and keeps refresh tokens device-bound. The broker needs an interactive Windows session; scheduled tasks, services, and SSH sessions should use a service principal or OIDC instead.

Requires a NAPT app registration in Microsoft Entra ID with the DeviceManagementApps.ReadWrite.All and Group.Read.All Microsoft Graph permissions (application permissions for CI/CD, delegated for interactive use). See the authentication documentation for setup instructions.

AuthConfig dataclass

One tenant's interactive sign-in settings.

Attributes:

Name Type Description
client_id str

Application (client) ID of the NAPT app registration in this tenant.

tenant_id str

Directory (tenant) ID the sign-in is scoped to.

username str | None

Account that signed in last (UPN), or None before the first login. Selects the right cached account when several tenants are signed in.

domain str | None

The tenant's default verified domain (e.g. contoso.com), looked up from Graph at login; None when the lookup was not possible.

display_name str | None

The tenant's organization display name, looked up alongside domain.

Source code in napt/auth/credentials.py
@dataclass(frozen=True)
class AuthConfig:
    """One tenant's interactive sign-in settings.

    Attributes:
        client_id: Application (client) ID of the NAPT app registration in
            this tenant.
        tenant_id: Directory (tenant) ID the sign-in is scoped to.
        username: Account that signed in last (UPN), or ``None`` before the
            first login. Selects the right cached account when several
            tenants are signed in.
        domain: The tenant's default verified domain (e.g. ``contoso.com``),
            looked up from Graph at login; ``None`` when the lookup was not
            possible.
        display_name: The tenant's organization display name, looked up
            alongside ``domain``.
    """

    client_id: str
    tenant_id: str
    username: str | None = None
    domain: str | None = None
    display_name: str | None = None

    @property
    def label(self) -> str | None:
        """Human-readable tenant label: ``"Contoso (contoso.com)"``, or ``None``."""
        if self.domain and self.display_name:
            return f"{self.display_name} ({self.domain})"
        return self.display_name or self.domain

label property

label: str | None

Human-readable tenant label: "Contoso (contoso.com)", or None.

AuthStore dataclass

Everything napt auth login remembers, keyed by tenant.

Attributes:

Name Type Description
active str | None

Tenant ID that commands use, or None before the first login.

tenants dict[str, AuthConfig]

Known tenants by tenant ID.

Source code in napt/auth/credentials.py
@dataclass
class AuthStore:
    """Everything `napt auth login` remembers, keyed by tenant.

    Attributes:
        active: Tenant ID that commands use, or ``None`` before the first
            login.
        tenants: Known tenants by tenant ID.
    """

    active: str | None = None
    tenants: dict[str, AuthConfig] = field(default_factory=dict)

AuthStatus dataclass

What napt auth status reports about the current credential.

Attributes:

Name Type Description
method str

Human-readable credential source, e.g. "service principal" or "interactive (broker)".

account str | None

Signed-in user (UPN) for delegated tokens, or the client ID for application tokens. None when the token could not be decoded.

tenant_id str | None

Tenant the token was issued for, when decodable.

client_id str | None

App registration the token was issued to, when decodable.

expires_at datetime | None

Access token expiry, when decodable.

permissions list[str]

Graph permissions carried by the token -- delegated scopes (scp) or application roles (roles).

missing list[str]

Required Graph permissions (REQUIRED_PERMISSIONS) absent from permissions.

Source code in napt/auth/credentials.py
@dataclass
class AuthStatus:
    """What `napt auth status` reports about the current credential.

    Attributes:
        method: Human-readable credential source, e.g. ``"service principal"``
            or ``"interactive (broker)"``.
        account: Signed-in user (UPN) for delegated tokens, or the client ID
            for application tokens. ``None`` when the token could not be
            decoded.
        tenant_id: Tenant the token was issued for, when decodable.
        client_id: App registration the token was issued to, when decodable.
        expires_at: Access token expiry, when decodable.
        permissions: Graph permissions carried by the token -- delegated
            scopes (``scp``) or application roles (``roles``).
        missing: Required Graph permissions (``REQUIRED_PERMISSIONS``) absent
            from ``permissions``.
    """

    method: str
    account: str | None = None
    tenant_id: str | None = None
    client_id: str | None = None
    expires_at: datetime | None = None
    permissions: list[str] = field(default_factory=list)
    missing: list[str] = field(default_factory=list)

load_auth_store

load_auth_store() -> AuthStore

Reads what previous napt auth login runs remembered.

Returns:

Type Description
AuthStore

The saved store, empty when no login has been run yet.

Raises:

Type Description
ConfigError

If the file exists but is unreadable or malformed.

Source code in napt/auth/credentials.py
def load_auth_store() -> AuthStore:
    """Reads what previous `napt auth login` runs remembered.

    Returns:
        The saved store, empty when no login has been run yet.

    Raises:
        ConfigError: If the file exists but is unreadable or malformed.
    """
    path = _auth_config_path()
    if not path.exists():
        return AuthStore()
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        tenants = {
            tenant_id: AuthConfig(
                client_id=entry["client_id"],
                tenant_id=tenant_id,
                username=entry.get("username"),
                domain=entry.get("domain"),
                display_name=entry.get("display_name"),
            )
            for tenant_id, entry in data.get("tenants", {}).items()
        }
        return AuthStore(active=data.get("active"), tenants=tenants)
    except (OSError, ValueError, KeyError, TypeError, AttributeError) as err:
        raise ConfigError(
            f"Cannot read saved auth config {path}: {err}. "
            "Delete the file and run 'napt auth login' again."
        ) from err

resolve_auth_config

resolve_auth_config(
    tenant_id: str | None = None, client_id: str | None = None
) -> AuthConfig | None

Determines the tenant and app registration for interactive sign-in.

The tenant is tenant_id or the active one from the last login; tenant_id may also be the default domain of a remembered tenant (contoso.com), which is mapped back to its ID. The client ID is client_id or the one remembered for that tenant. The remembered username carries over only when the client ID is unchanged, since a different app registration means a different cached session. AZURE_* environment variables are deliberately not consulted: they describe a non-interactive credential, not which app a person signs in to.

Parameters:

Name Type Description Default
tenant_id str | None

Explicit tenant ID or remembered domain (from --tenant-id).

None
client_id str | None

Explicit client ID (from --client-id).

None

Returns:

Type Description
AuthConfig | None

The resolved config, or None if the tenant or client ID is still

AuthConfig | None

unknown.

Raises:

Type Description
ConfigError

If a saved config file exists but is malformed.

Source code in napt/auth/credentials.py
def resolve_auth_config(
    tenant_id: str | None = None, client_id: str | None = None
) -> AuthConfig | None:
    """Determines the tenant and app registration for interactive sign-in.

    The tenant is ``tenant_id`` or the active one from the last login;
    ``tenant_id`` may also be the default domain of a remembered tenant
    (``contoso.com``), which is mapped back to its ID. The client ID is
    ``client_id`` or the one remembered for that tenant. The remembered
    username carries over only when the client ID is unchanged, since a
    different app registration means a different cached session.
    ``AZURE_*`` environment variables are deliberately not consulted: they
    describe a non-interactive credential, not which app a person signs in
    to.

    Args:
        tenant_id: Explicit tenant ID or remembered domain (from
            ``--tenant-id``).
        client_id: Explicit client ID (from ``--client-id``).

    Returns:
        The resolved config, or ``None`` if the tenant or client ID is still
        unknown.

    Raises:
        ConfigError: If a saved config file exists but is malformed.
    """
    store = load_auth_store()
    tenant = tenant_id or store.active
    if not tenant:
        return None
    if tenant not in store.tenants:
        by_domain = {
            cfg.domain.lower(): tid for tid, cfg in store.tenants.items() if cfg.domain
        }
        tenant = by_domain.get(tenant.lower(), tenant)
    known = store.tenants.get(tenant)
    client = client_id or (known.client_id if known else None)
    if not client:
        return None
    username = known.username if known and known.client_id == client else None
    return AuthConfig(
        client_id=client,
        tenant_id=tenant,
        username=username,
        domain=known.domain if known else None,
        display_name=known.display_name if known else None,
    )

get_credential

get_credential() -> EnvironmentCredential

Builds the service principal credential from environment variables.

Reads AZURE_CLIENT_ID, AZURE_TENANT_ID and either AZURE_CLIENT_SECRET or AZURE_CLIENT_CERTIFICATE_PATH, and uses the .default scope, suitable for application permissions.

Returns:

Type Description
EnvironmentCredential

The environment-backed credential; acquiring a token from it fails

EnvironmentCredential

with ClientAuthenticationError when the variables are not all set.

Source code in napt/auth/credentials.py
def get_credential() -> EnvironmentCredential:
    """Builds the service principal credential from environment variables.

    Reads ``AZURE_CLIENT_ID``, ``AZURE_TENANT_ID`` and either
    ``AZURE_CLIENT_SECRET`` or ``AZURE_CLIENT_CERTIFICATE_PATH``, and uses
    the `.default` scope, suitable for application permissions.

    Returns:
        The environment-backed credential; acquiring a token from it fails
        with ClientAuthenticationError when the variables are not all set.
    """
    return EnvironmentCredential()

msal_error

msal_error(result: dict[str, Any]) -> str

Summarizes a failed MSAL token result as code: first line.

Parameters:

Name Type Description Default
result dict[str, Any]

The dict MSAL returns when no access token was issued.

required

Returns:

Type Description
str

The error code followed by the first line of its description.

Source code in napt/auth/credentials.py
def msal_error(result: dict[str, Any]) -> str:
    """Summarizes a failed MSAL token result as ``code: first line``.

    Args:
        result: The dict MSAL returns when no access token was issued.

    Returns:
        The error code followed by the first line of its description.

    """
    code = result.get("error", "unknown_error")
    description = str(result.get("error_description", "")).splitlines()
    return f"{code}: {description[0]}" if description else str(code)

remember_tenant

remember_tenant(config: AuthConfig) -> Path

Records config in the auth store as the active tenant.

Parameters:

Name Type Description Default
config AuthConfig

Tenant and client settings to store; replaces any entry for the same tenant.

required

Returns:

Type Description
Path

Path of the auth store file that was written.

Source code in napt/auth/credentials.py
def remember_tenant(config: AuthConfig) -> Path:
    """Records ``config`` in the auth store as the active tenant.

    Args:
        config: Tenant and client settings to store; replaces any entry
            for the same tenant.

    Returns:
        Path of the auth store file that was written.

    """
    store = load_auth_store()
    store.tenants[config.tenant_id] = config
    store.active = config.tenant_id
    return _save_auth_store(store)

login

login(
    *,
    tenant_id: str | None = None,
    client_id: str | None = None,
    use_broker: bool = True
) -> AuthStatus

Signs in to a tenant and makes it the active one.

If the tenant already has a usable cached session, it is reused silently -- so napt auth login --tenant-id <id> switches between signed-in tenants without a prompt. Otherwise the Windows broker (when the MSAL broker runtime is installed and use_broker is true) or the system browser opens, and the resulting account is stored in NAPT's encrypted token cache. The tenant, client ID, and signed-in username are remembered so later logins need no arguments.

Parameters:

Name Type Description Default
tenant_id str | None

Tenant ID; defaults to the active tenant.

None
client_id str | None

App registration client ID; overrides the one remembered for the tenant.

None
use_broker bool

Prefer the OS broker over a browser when available.

True

Returns:

Type Description
AuthStatus

Status of the token now in use, including any missing permissions.

Raises:

Type Description
AuthError

If no app registration is configured or the sign-in fails.

ConfigError

If the saved auth config is malformed.

Source code in napt/auth/credentials.py
def login(
    *,
    tenant_id: str | None = None,
    client_id: str | None = None,
    use_broker: bool = True,
) -> AuthStatus:
    """Signs in to a tenant and makes it the active one.

    If the tenant already has a usable cached session, it is reused silently
    -- so ``napt auth login --tenant-id <id>`` switches between signed-in
    tenants without a prompt. Otherwise the Windows broker (when the MSAL
    broker runtime is installed and ``use_broker`` is true) or the system
    browser opens, and the resulting account is stored in NAPT's
    encrypted token cache. The tenant, client ID, and signed-in username are
    remembered so later logins need no arguments.

    Args:
        tenant_id: Tenant ID; defaults to the active tenant.
        client_id: App registration client ID; overrides the one remembered
            for the tenant.
        use_broker: Prefer the OS broker over a browser when available.

    Returns:
        Status of the token now in use, including any missing permissions.

    Raises:
        AuthError: If no app registration is configured or the sign-in fails.
        ConfigError: If the saved auth config is malformed.
    """
    from napt.logging import get_global_logger

    logger = get_global_logger()

    config = resolve_auth_config(tenant_id, client_id)
    if config is None:
        raise AuthError(_HINT_NO_CLIENT_CONFIG)

    broker = use_broker and _broker_available()

    try:
        cached = _acquire_silent(config, use_broker=broker)
    except AuthError as err:
        logger.verbose("AUTH", f"Cached session unusable, signing in again: {err}")
        cached = None
    if cached is not None:
        remember_tenant(_with_tenant_label(config, cached))
        logger.info(
            "AUTH",
            f"Reusing signed-in session for {config.username} "
            f"in tenant {config.tenant_id}",
        )
        return _status_from_token(cached, _interactive_method(broker))

    app = _build_public_client(config, use_broker=broker)
    logger.verbose(
        "AUTH",
        f"Signing in to tenant {config.tenant_id} as app {config.client_id} "
        f"({'broker' if broker else 'browser'})",
    )

    def _announce(ui: str = "browser", **_: Any) -> None:
        if ui == "broker":
            print("A sign-in window will open. Choose your work account.")
        else:
            print("Opening your browser to sign in...")

    try:
        result = app.acquire_token_interactive(
            GRAPH_SCOPES,
            prompt="select_account",
            timeout=LOGIN_TIMEOUT,
            parent_window_handle=(
                msal.PublicClientApplication.CONSOLE_WINDOW_HANDLE if broker else None
            ),
            on_before_launching_ui=_announce,
        )
    except Exception as err:  # MSAL surfaces transport/broker failures as raw errors
        raise AuthError(f"{_HINT_LOGIN_FAILED}Details: {err}") from err

    if "access_token" not in result:
        logger.debug("AUTH", f"MSAL interactive response: {result}")
        detail = msal_error(result)
        if result.get("correlation_id"):
            detail += f" (correlation_id {result['correlation_id']})"
        # _broker_status is a pymsalruntime enum; compare by name.
        canceled = str(result.get("_broker_status", "")).endswith("Status_UserCanceled")
        hint = _HINT_LOGIN_CANCELED if canceled else _HINT_LOGIN_FAILED
        raise AuthError(f"{hint}Details: {detail}")

    status = _status_from_token(result["access_token"], _interactive_method(broker))
    claims = result.get("id_token_claims")
    username = (
        claims.get("preferred_username") if isinstance(claims, dict) else None
    ) or status.account
    if not status.account:
        status.account = username

    signed_in = AuthConfig(
        client_id=config.client_id,
        tenant_id=config.tenant_id,
        username=username,
    )
    saved_to = remember_tenant(_with_tenant_label(signed_in, result["access_token"]))
    logger.verbose("AUTH", f"Saved sign-in settings to {saved_to}")
    return status

logout

logout(*, all_tenants: bool = False) -> list[str]

Removes cached interactive sessions.

Signs the active tenant's account out of NAPT's token cache (and the OS broker, when it was used). With all_tenants, every remembered tenant is signed out. Client and tenant IDs are kept so the next napt auth login needs no arguments.

Parameters:

Name Type Description Default
all_tenants bool

Sign out of every remembered tenant, not just the active one.

False

Returns:

Type Description
list[str]

Tenant IDs that had a session removed (empty if none was cached).

Raises:

Type Description
AuthError

If the OS-encrypted token cache cannot be opened.

ConfigError

If the saved auth config is malformed.

Source code in napt/auth/credentials.py
def logout(*, all_tenants: bool = False) -> list[str]:
    """Removes cached interactive sessions.

    Signs the active tenant's account out of NAPT's token cache (and the OS
    broker, when it was used). With ``all_tenants``, every remembered tenant
    is signed out. Client and tenant IDs are kept so the next
    `napt auth login` needs no arguments.

    Args:
        all_tenants: Sign out of every remembered tenant, not just the
            active one.

    Returns:
        Tenant IDs that had a session removed (empty if none was cached).

    Raises:
        AuthError: If the OS-encrypted token cache cannot be opened.
        ConfigError: If the saved auth config is malformed.
    """
    store = load_auth_store()
    if all_tenants:
        targets = list(store.tenants.values())
    else:
        active = store.tenants.get(store.active or "")
        targets = [active] if active else []

    signed_out: list[str] = []
    broker = _broker_available()
    for config in targets:
        if config.username is None:
            continue
        app = _build_public_client(config, use_broker=broker)
        accounts = app.get_accounts(username=config.username)
        for account in accounts:
            app.remove_account(account)
        store.tenants[config.tenant_id] = AuthConfig(
            client_id=config.client_id,
            tenant_id=config.tenant_id,
            domain=config.domain,
            display_name=config.display_name,
        )
        if accounts:
            signed_out.append(config.tenant_id)
    if targets:
        _save_auth_store(store)
    return signed_out

get_status

get_status() -> AuthStatus | None

Reports which credential NAPT would use right now, or None.

Resolves a token exactly as get_access_token does -- so the answer reflects what napt upload will do -- and decodes it for display.

Returns:

Type Description
AuthStatus | None

The current credential's status, or None when nothing is

AuthStatus | None

configured or signed in.

Raises:

Type Description
AuthError

If a credential is configured but fails (for example, a saved session that can no longer be refreshed).

Source code in napt/auth/credentials.py
def get_status() -> AuthStatus | None:
    """Reports which credential NAPT would use right now, or ``None``.

    Resolves a token exactly as
    [get_access_token][napt.auth.credentials.get_access_token] does -- so
    the answer reflects what `napt upload` will do -- and decodes it for
    display.

    Returns:
        The current credential's status, or ``None`` when nothing is
        configured or signed in.

    Raises:
        AuthError: If a credential is configured but fails (for example, a
            saved session that can no longer be refreshed).
    """
    try:
        token = get_credential().get_token(*GRAPH_SCOPES).token
        return _status_from_token(token, _describe_noninteractive_method())
    except ClientAuthenticationError:
        pass

    config = _interactive_config()
    if config is not None:
        broker = _broker_available()
        token = _acquire_silent(config, use_broker=broker)
        if token is not None:
            return _status_from_token(token, _interactive_method(broker))

    token = _azure_cli_token()
    if token is not None:
        return _status_from_token(token, "azure cli")
    return None

get_access_token

get_access_token() -> str

Acquires a Microsoft Graph access token.

Tries the non-interactive chain from get_credential first, then the session saved by napt auth login, then an existing Azure CLI (az login) session. Never opens a browser: an interactive user who has not logged in is told to run napt auth login.

Returns:

Type Description
str

Bearer token string for use in Authorization headers.

Raises:

Type Description
AuthError

If no credential is available or the saved session can no longer be refreshed, with guidance on what to do.

Example

Get a token and use it in a request:

from napt.auth.credentials import get_access_token

token = get_access_token()
headers = {"Authorization": f"Bearer {token}"}

Source code in napt/auth/credentials.py
def get_access_token() -> str:
    """Acquires a Microsoft Graph access token.

    Tries the non-interactive chain from
    [get_credential][napt.auth.credentials.get_credential] first, then the session
    saved by `napt auth login`, then an existing Azure CLI (`az login`)
    session. Never opens a browser: an interactive user who has not logged
    in is told to run `napt auth login`.

    Returns:
        Bearer token string for use in Authorization headers.

    Raises:
        AuthError: If no credential is available or the saved session can no
            longer be refreshed, with guidance on what to do.

    Example:
        Get a token and use it in a request:
            ```python
            from napt.auth.credentials import get_access_token

            token = get_access_token()
            headers = {"Authorization": f"Bearer {token}"}
            ```

    """
    try:
        return get_credential().get_token(*GRAPH_SCOPES).token
    except ClientAuthenticationError:
        pass

    config = _interactive_config()
    if config is not None:
        token = _acquire_silent(config, use_broker=_broker_available())
        if token is not None:
            return token

    token = _azure_cli_token()
    if token is not None:
        return token

    raise AuthError(_HINT_NOT_LOGGED_IN)

napt.auth.registration

Entra ID app registration provisioning for napt auth setup.

Creates -- or brings up to spec -- the app registration that napt.auth.credentials signs in with, so an administrator never has to click through the portal:

  • The application object with http://localhost and the Windows broker redirect URI as a Mobile-and-desktop platform, and the Microsoft Graph permissions NAPT needs declared as both application permissions (CI/CD) and delegated permissions (interactive sign-in).
  • Its service principal, with tenant-wide admin consent for both kinds of permission.
  • Optionally, a federated identity credential that lets a CI/CD platform's workflow obtain tokens through OIDC with no client secret. The issuer and subject come from the user; NAPT carries no platform-specific knowledge.

Every step is idempotent: an existing registration (found by display name or --client-id) is patched with only what is missing, and rerunning on a complete registration changes nothing.

The run is bootstrapped with a short-lived token from the Microsoft Graph Command Line Tools first-party application -- the same one Connect-MgGraph uses -- requested in the browser and held in memory only. It needs an account holding at least the Application Administrator role. NAPT does not store that account or its tokens; the browser may keep its own sign-in.

Redirect URIs and permissions are always written to the application object, never to the service principal, where a directory sync could drop them.

SetupSpec dataclass

What napt auth setup should provision.

Attributes:

Name Type Description
tenant_id str

Directory (tenant) ID to provision in.

display_name str

Display name of the app registration to find or create.

client_id str | None

Existing registration to bring up to spec instead of matching by display name.

federated_issuer str | None

OIDC issuer URL of the CI platform to trust (for example GitHub Actions' https://token.actions.githubusercontent.com), or None for no federated credential.

federated_subject str | None

Subject claim the platform presents for the workflow that may obtain tokens; its format is defined by the platform (for GitHub Actions, repo:owner/name:ref:refs/heads/main).

federated_audience str

Audience claim; Entra's standard value is the default.

federated_name str | None

Display name of the credential; derived from the subject when not given.

adopt bool

Take over a registration matched by display name that NAPT did not create (no provenance stamp). Not needed when client_id names the registration explicitly.

Source code in napt/auth/registration.py
@dataclass(frozen=True)
class SetupSpec:
    """What `napt auth setup` should provision.

    Attributes:
        tenant_id: Directory (tenant) ID to provision in.
        display_name: Display name of the app registration to find or
            create.
        client_id: Existing registration to bring up to spec instead of
            matching by display name.
        federated_issuer: OIDC issuer URL of the CI platform to trust (for
            example GitHub Actions' ``https://token.actions.githubusercontent.com``),
            or ``None`` for no federated credential.
        federated_subject: Subject claim the platform presents for the
            workflow that may obtain tokens; its format is defined by the
            platform (for GitHub Actions, ``repo:owner/name:ref:refs/heads/main``).
        federated_audience: Audience claim; Entra's standard value is the
            default.
        federated_name: Display name of the credential; derived from the
            subject when not given.
        adopt: Take over a registration matched by display name that NAPT
            did not create (no provenance stamp). Not needed when
            ``client_id`` names the registration explicitly.
    """

    tenant_id: str
    display_name: str = "NAPT"
    client_id: str | None = None
    federated_issuer: str | None = None
    federated_subject: str | None = None
    federated_audience: str = FEDERATED_AUDIENCE_DEFAULT
    federated_name: str | None = None
    adopt: bool = False

    def __post_init__(self) -> None:
        """Rejects a federated credential given only an issuer or only a subject."""
        if bool(self.federated_issuer) != bool(self.federated_subject):
            raise ConfigError(
                "A federated credential needs both an issuer and a subject "
                "(--federated-issuer and --federated-subject)"
            )

    @property
    def federated_credential_name(self) -> str | None:
        """Name of the federated credential: given, or derived from the subject."""
        if not self.federated_subject:
            return None
        if self.federated_name:
            return self.federated_name
        # Entra allows letters, digits, and hyphens, up to 120 characters.
        derived = re.sub(r"[^A-Za-z0-9]+", "-", self.federated_subject).strip("-")
        return f"napt-{derived}"[:120]

federated_credential_name property

federated_credential_name: str | None

Name of the federated credential: given, or derived from the subject.

__post_init__

__post_init__() -> None

Rejects a federated credential given only an issuer or only a subject.

Source code in napt/auth/registration.py
def __post_init__(self) -> None:
    """Rejects a federated credential given only an issuer or only a subject."""
    if bool(self.federated_issuer) != bool(self.federated_subject):
        raise ConfigError(
            "A federated credential needs both an issuer and a subject "
            "(--federated-issuer and --federated-subject)"
        )

SetupResult dataclass

What napt auth setup found or created.

Attributes:

Name Type Description
tenant_id str

Tenant the registration lives in.

client_id str

Application (client) ID to use with NAPT.

display_name str

The registration's display name.

created bool

Whether the application object was created by this run.

adopted bool

Whether this run took over a registration NAPT did not create.

needs_adopt bool

The registration matched by name carries no NAPT stamp and adopt was not given; nothing was changed.

previous_spec int | None

Spec version the registration was stamped with before this run, or None if it had no stamp.

changes list[str]

Human-readable list of what this run added; empty when the registration was already complete.

Source code in napt/auth/registration.py
@dataclass
class SetupResult:
    """What `napt auth setup` found or created.

    Attributes:
        tenant_id: Tenant the registration lives in.
        client_id: Application (client) ID to use with NAPT.
        display_name: The registration's display name.
        created: Whether the application object was created by this run.
        adopted: Whether this run took over a registration NAPT did not
            create.
        needs_adopt: The registration matched by name carries no NAPT stamp
            and ``adopt`` was not given; nothing was changed.
        previous_spec: Spec version the registration was stamped with before
            this run, or ``None`` if it had no stamp.
        changes: Human-readable list of what this run added; empty when the
            registration was already complete.
    """

    tenant_id: str
    client_id: str
    display_name: str
    created: bool = False
    adopted: bool = False
    needs_adopt: bool = False
    previous_spec: int | None = None
    changes: list[str] = field(default_factory=list)

setup_app_registration

setup_app_registration(spec: SetupSpec) -> SetupResult

Creates or completes the NAPT app registration in a tenant.

Signs an administrator in, then finds or creates the application, adds any missing redirect URIs and Graph permissions, ensures the service principal exists with admin consent for every permission, and adds the OIDC federated credential when requested. Finally records the tenant and client ID as the active tenant for napt auth login.

Parameters:

Name Type Description Default
spec SetupSpec

What to provision.

required

Returns:

Type Description
SetupResult

The registration's IDs and the list of changes made.

Raises:

Type Description
AuthError

If the administrator sign-in fails or lacks the rights to manage applications.

ConfigError

If the display name is ambiguous, a given client ID does not exist, or Graph reports unexpected permission data.

NetworkError

On Graph API failures.

Example

Provision a tenant and trust a CI workflow through OIDC:

from napt.auth.registration import SetupSpec, setup_app_registration

result = setup_app_registration(
    SetupSpec(
        tenant_id="<tenant id>",
        federated_issuer="https://token.actions.githubusercontent.com",
        federated_subject="repo:contoso/intune-apps:ref:refs/heads/main",
    )
)
print(result.client_id, result.changes)

Source code in napt/auth/registration.py
def setup_app_registration(spec: SetupSpec) -> SetupResult:
    """Creates or completes the NAPT app registration in a tenant.

    Signs an administrator in, then finds or creates the application,
    adds any missing redirect URIs and Graph permissions, ensures the
    service principal exists with admin consent for every permission, and
    adds the OIDC federated credential when requested. Finally records
    the tenant and client ID as the active tenant for `napt auth login`.

    Args:
        spec: What to provision.

    Returns:
        The registration's IDs and the list of changes made.

    Raises:
        AuthError: If the administrator sign-in fails or lacks the rights
            to manage applications.
        ConfigError: If the display name is ambiguous, a given client ID
            does not exist, or Graph reports unexpected permission data.
        NetworkError: On Graph API failures.

    Example:
        Provision a tenant and trust a CI workflow through OIDC:
            ```python
            from napt.auth.registration import SetupSpec, setup_app_registration

            result = setup_app_registration(
                SetupSpec(
                    tenant_id="<tenant id>",
                    federated_issuer="https://token.actions.githubusercontent.com",
                    federated_subject="repo:contoso/intune-apps:ref:refs/heads/main",
                )
            )
            print(result.client_id, result.changes)
            ```

    """
    from napt.logging import get_global_logger

    logger = get_global_logger()
    token = _bootstrap_token(spec.tenant_id)

    roles, scopes, graph_sp_id = _graph_permission_ids(token)
    result = SetupResult(
        tenant_id=spec.tenant_id, client_id="", display_name=spec.display_name
    )

    app = _ensure_application(token, spec, roles, scopes, result)
    result.client_id = app["appId"]
    result.display_name = app.get("displayName") or spec.display_name
    if result.needs_adopt:
        return result
    logger.info("AUTH", f"App registration: {result.display_name} ({result.client_id})")

    sp_id = _ensure_service_principal(token, app, result)
    _ensure_app_role_consent(token, sp_id, graph_sp_id, roles, result)
    _ensure_delegated_consent(token, sp_id, graph_sp_id, result)
    _ensure_federated_credential(token, app, spec, result)

    # Keep an existing session for this tenant when the client ID is unchanged.
    known = load_auth_store().tenants.get(spec.tenant_id)
    if known is not None and known.client_id == result.client_id:
        remember_tenant(known)
    else:
        remember_tenant(
            AuthConfig(client_id=result.client_id, tenant_id=spec.tenant_id)
        )
    logger.verbose("AUTH", "Saved tenant and client ID for 'napt auth login'")
    return result