Providers/Okta/Public/Get-OktaAccessToken.ps1

function Get-OktaAccessToken {
    <#
    .SYNOPSIS
        Exchanges the service app's private key for an Okta API access token

    .DESCRIPTION
        Runs the client credentials flow with private_key_jwt against the org authorisation
        server. This is the "later auth" half of the module: the SSWS token is only needed
        once, to create the app, and every run after that can authenticate with the key this
        reads off disk.

        The org authorisation server (/oauth2/v1/token) is used rather than the default
        custom one (/oauth2/default/v1/token). Only the org server issues tokens carrying
        okta.* scopes, and a token from the default server will be accepted by the token
        endpoint and then rejected by every management API call, which is a confusing way to
        find out.

        No client secret is involved anywhere. A service app configured for private_key_jwt
        does not have one, which is the reason to prefer it for a lab that lives in a
        repository: there is nothing to leak into a transcript.

    .PARAMETER CredentialPath
        Path to the credential file written by New-OktaServiceApp. Defaults to the
        per-user location for the connected org.

    .PARAMETER OrgUrl
        Org to authenticate against. Defaults to the one recorded in the credential file.

    .PARAMETER Scope
        Scopes to request. Defaults to the scopes recorded in the credential file, which are
        the ones actually granted to the app; asking for more than was granted fails.

    .PARAMETER VaultPassword
        Password used to unlock the SecretStore vault, when the credential is stored in one.
        Ignored for the DPAPI default, which needs no password.

    .PARAMETER AsPlainText
        Return the raw token string rather than the result object. Convenient for piping into
        another tool, and deliberately not the default.

    .OUTPUTS
        PSCustomObject with AccessToken, TokenType, Scopes, ExpiresInSeconds and ExpiresUtc,
        or a String when -AsPlainText is used.

    .EXAMPLE
        Get-OktaAccessToken
        Mints a token using the saved credential for the connected org

    .EXAMPLE
        $token = Get-OktaAccessToken -AsPlainText
        curl -H "Authorization: Bearer $token" https://trial-123456.okta.com/api/v1/users

    .EXAMPLE
        Get-OktaAccessToken -Scope okta.users.read
        Requests a narrower token than the app is entitled to

    .NOTES
        Author: Jeffrey Stuhr
        Version: 1.0.0
        Last Updated: 2026-08-07

        The returned token is a bearer credential with whatever admin role the app holds.
        Treat it exactly as you would the SSWS token it replaces.

    .LINK
        New-OktaServiceApp
        Connect-OktaEnvironment
    #>


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'CredentialPath',
        Justification = 'A file path, not a credential. The key it points at never appears here.')]
    [CmdletBinding()]
    [OutputType([PSCustomObject], [string])]
    param(
        [Parameter()]
        [string]$CredentialPath,

        [Parameter()]
        [string]$OrgUrl,

        [Parameter()]
        [string[]]$Scope,

        [Parameter()]
        [System.Security.SecureString]$VaultPassword,

        [Parameter()]
        [switch]$AsPlainText
    )

    if (-not $CredentialPath) {
        $connection = Get-OktaConnection -AllowNone
        $resolvedOrg = if ($OrgUrl) { $OrgUrl } elseif ($connection) { $connection.OrgUrl } else { $null }

        if (-not $resolvedOrg) {
            throw ('Specify -CredentialPath or -OrgUrl, or connect first, so the credential ' +
                'file can be located.')
        }
        $CredentialPath = Get-OktaCredentialPath -OrgUrl $resolvedOrg
    }

    $importArgs = @{ Path = $CredentialPath }
    if ($VaultPassword) { $importArgs.VaultPassword = $VaultPassword }
    $credential = Import-OktaAppCredential @importArgs

    $tokenOrg = if ($OrgUrl) { $OrgUrl.TrimEnd('/') } else { $credential.orgUrl.TrimEnd('/') }
    $audience = "$tokenOrg/oauth2/v1/token"

    $requestedScopes = if ($Scope) { $Scope } else { @($credential.scopes) }
    if (-not $requestedScopes -or $requestedScopes.Count -eq 0) {
        throw ("The credential at '$CredentialPath' records no scopes and none were passed. " +
            'Pass -Scope with the scopes the app was granted.')
    }

    $assertion = New-OktaClientAssertion -PrivateJwk $credential.privateJwk `
        -ClientId $credential.clientId -Audience $audience

    $form = @(
        'grant_type=client_credentials'
        'scope={0}' -f [uri]::EscapeDataString(($requestedScopes -join ' '))
        'client_assertion_type={0}' -f
            [uri]::EscapeDataString('urn:ietf:params:oauth:client-assertion-type:jwt-bearer')
        'client_assertion={0}' -f [uri]::EscapeDataString($assertion)
    ) -join '&'

    # An explicit connection with no Authorization header. The assertion in the body is the
    # credential here, and this also keeps the call from re-entering Get-OktaConnection,
    # which is what would otherwise recurse when it renews an expiring token.
    $tokenConnection = @{ OrgUrl = $tokenOrg; AuthorizationHeader = $null }

    $response = Invoke-OktaRequest -Method POST -Path '/oauth2/v1/token' -Body $form `
        -ContentType 'application/x-www-form-urlencoded' -Connection $tokenConnection

    if (-not $response.access_token) {
        throw 'The token endpoint returned no access_token.'
    }

    if ($AsPlainText) { return $response.access_token }

    return [PSCustomObject]@{
        AccessToken      = $response.access_token
        TokenType        = $response.token_type
        Scopes           = @($response.scope -split ' ' | Where-Object { $_ })
        ExpiresInSeconds = $response.expires_in
        ExpiresUtc       = [DateTime]::UtcNow.AddSeconds([int]$response.expires_in)
        ClientId         = $credential.clientId
        OrgUrl           = $tokenOrg
        CredentialPath   = $CredentialPath
    }
}