App to App Account Linking(为自有App配置指导)

本文将简单介绍开发从自有App跳转到Alexa App账号下获取技能的应用账号登录链接服务。

背景信息

Alexa音箱要使用Skill控制自有App下的设备,需要从自有App跳转登录到Amazon Alexa语音平台上,可以通过开发一个App Account Linking,用于直接登录Alexa账号的服务来获取并可以开启Alexa账号下的Skill,该服务避免用户手动进入Alexa App内查找到Skill,再进行点击输入账号密码得操作。

概述

通过开发一个App to App Account Linking服务实现从自有App中直接登录Alexa应用账号获取该账号下的Skill,可以有以下两种打开链接的方式:

  • 从iOS和Android应用程序启动Alexa App。

  • 使用Amazon Web登录,并作为iOS和Android的后备方案。

App to App Account Linking允许用户从自有App或网站开始,将其Alexa用户身份信息与另一服务中的身份信息进行链接。当您从自有App启动App to App Account Linking时,您的用户可以:

  • 通过自有App发现你的Alexa技能。

  • 从自有App中启动技能并和帐户链接。

  • 当他们在移动设备上登录自有App和Alexa App两个应用程序时,无需输入他们的帐户密码即可直接链接他们的帐户。

  • 当Alexa App未安装在他们的移动设备上时,可以使用Login with Amazon(LWA)从自有App链接他们的帐户,详见Login with Amazon(LWA)

实现App to App Account Linking的其他工作流程还有以下两种情况:

  • 仅使用Alexa App(App内打开浏览器):用户完全在Alexa App内完成帐户链接,这是最常见的流程。有关详细信息,请参见Add Account Linking to Your Alexa Skill

说明

如果您有一个应用程序或网站,我们建议您在Alexa App(浏览器)之外实施一个App to App Account Linking服务,即本文所指的App to App Account Linking。因此在本文中,App to App Account Linking专指从自有App开始的App to App Account Linking。

开发对象

Alexa App的工作流程可用于iOS和Android。因此,您实施的流程取决于您是在开发iOS应用程序、Android应用程序还是网站:

  • iOS和Android

    将Alexa App流作为主要流,将LWA流作为未安装Alexa App时的备用流。

  • 网站(Websites)

    实施LWA流程。

使用流程

在使用App to App Account Linking期间,用户将执行以下工作流程。

您的自有App为用户提供了启用您的技能并将其帐户与Alexa链接的选项。如果用户选择链接其帐户,出现以下两种情况之一,具体取决于移动设备上是否安装了Alexa App。

  • Alexa App

    如果设备上安装了Alexa App,Alexa App将启动并要求用户确认帐户链接请求。确认请求后,用户将返回到您的应用程序。

  • LWA

    如果设备上未安装Alexa App,将打开一个带有LWA的浏览器窗口,用户可以输入其Amazon账号信息或创建Amazon帐户。然后要求用户授予您链接帐户的技能权限。确认请求后,用户将返回到自有App中。

在链接帐户并且用户使用您的技能后,该技能使用与仅使用Alexa App有相同的工作流程,详见Alexa App only。在任何情况下,禁用该技能都会导致帐户取消链接。

以下是App to App Account Linking的示例屏幕截图。如上所述,App to App Account Linking工作流程取决于用户是否安装了Alexa App。

流程设计

App to App Account Linking通过使用OAuth 2.0工作。

底层交互流程介绍:

  1. 用户在其移动设备上安装自有App,并登录到自有App。

  1. 您的自有App向用户提供了启用您的技能并将他们的帐户与Alexa链接的选项,并提供示例信息(例如,"您现在可以通过Alexa语音通过云智能App订购智能灯泡")。用户确认链接请求。

  2. 接下来会发生什么取决于用户设备上是否安装了Alexa App。

    • 如果安装了Alexa App:

      • 在自有App中使用Alexa App URL和授权请求参数启动Alexa App。

      • Alexa App启动并询问用户是否希望将Alexa链接到您的服务。

      • 用户确认链接请求。

      • Alexa App使用redirect URL将用户返回自有App中,并将用户的Amazon授权代码作为redirect URL的一部分发送。

    • 如果未安装Alexa App:

      • 自有App使用应用程序内置浏览器页签(非本机内浏览器App)中的LWA fallback URL启动LWA,并使用相关授权请求参数。

      • LWA启动并要求用户登录他们的Amazon帐户。

      • LWA询问用户是否希望将Alexa链接到您的服务。

      • 用户确认链接请求。

      • LWA使用您的redirect URL将用户发送回您的应用程序,并将用户的Amazon授权码作为redirect URL的一部分发送。

  3. 您的后端服务器调用LWA授权服务URL,并将在上一步中检索到的Amazon授权代码交换为Amazon访问令牌。

  4. 您的后端服务器调用您的授权服务器以获取用户的授权代码(用于他们在您的服务中的帐户)。

  5. 后端服务器使用用户的Amazon访问令牌和用户的服务授权代码调用Alexa Skill激活API,以启用技能并链接帐户。

  6. Alexa转到自有App的Access token URL,将您的服务的用户授权码交换为您的服务的访问令牌,从而完成App to App Account Linking。

开发步骤介绍

建议开发使用以下库和语言提供iOS应用(10.0或更高版本)和Android应用的代码示例:

  1. 在自有App中显示帐户链接选项。

    自有App需要有一个启动页面,用户可以从中启动技能启用和帐户链接。此页面应显示用户可以通过将其帐户链接到Alexa来做什么,例如:通过语音订购智能彩灯。您允许用户启动帐户链接的方式取决于您希望为用户创建的体验。您可以在多个位置向用户提供帐户链接:例如,您可以在用户注册自有App账号后为其提供启用帐户链接的选项,您可以在App内设置页面的"链接到Alexa"页面上为其提供按钮,等等。在任何情况下,请务必解释与Alexa链接帐户的方便有效之处。

    • 将App to App Account Linking按钮添加到iOS应用内,请执行以下操作:

      1. 以使用Xcode IDE为例,打开storyboard。

      2. 将UIbutton添加到视图控制器,并将其命名为"Link your account with Alexa"。考虑使用一个名字来表示用户通过链接他们的帐户将得到的好处,例如"用Alexa听你的音乐","订购一个懂你的智能烤箱"。

      3. 在Xcode IDE的左上角,单击"Show the assistant editor"。

      4. 通过将按钮拖动到代码编辑器,为按钮创建操作连接。

    • 将App to App Account Linking按钮添加到Android App,请执行以下操作:

      1. 在Android Studio中,在Design Mode下,打开要添加App to App Account Linking按钮的页面布局。

      2. 从选项板中,将新按钮拖放到布局中。

      3. 为按钮定义一个ID。

      4. 在页面的代码中,初始化App to App Account Linking按钮,并定义其onClick侦听器行为用以启动,如下例所示。

        class AppToAppFragment : Fragment() {
          private lateinit var appToAppButton: Button
          override fun onCreateView(
            inflater: LayoutInflater,
            container: ViewGroup?,
            savedInstanceState: Bundle?
            ): View? {
              val root = inflater.inflate(R.layout.appToAppPage, container, false)
              initComponents(root)
              appToApp.setOnClickListener {
                doAppToApp() 
              }
          }
          private fun initComponents(root: View) {
            appToAppButton = root.findViewById(R.id.appToAppAccountLinkingButton)
          }
        }

  1. 为自有App启用Universal Link(iOS)或App Link(Android)。要允许Alexa App将用户重新跳转向到自有App,您必须为iOS应用程序启用Universal Link或为Android应用程序启用App Link。

    说明

    Universal Link和App Link必须符合指定的URI语法。记住您添加的域和路径,因为在Alexa开发者控制台中将redirect URL添加到Skill详细信息时需要它们。

  1. 配置使用App to App Account Linking的Skill。

    您可以使用Alexa Developer console(Alexa开发者控制台)、Alexa Skills Kit命令行界面(ASK CLI)或Alexa skill Management API(SMAPI)来配置您的Skill(技能)。在此配置过程中,您可以指定自有App的redirect URL、access token URL等内容。

    • 使用Alexa Developer console配置。

      1. 登录Alexa开发者控制台

      2. 在列表中找到你的Skill。在你的Skill下操作,选择编辑。

      3. 在左侧,单击TOOLS,然后单击Account Link

      4. 如果您以前为您的Skill配置了Account Link,请启用"Allow users to link their account to your skill from within your application or website"。

      5. 对于授权授予类型,如果尚未选择授权代码授予,请选择授权代码授予。App to App Account Linking仅支持授权代码授予。

      6. 对于其他设置,请使用配置Account Link中描述的设置,并在填写名为"redirect URL"的字段时注意以下附加说明:

        • 对于redirect URL,请添加URL和相关参数说明中描述的redirect URL。

        • redirect URL必须符合指定的URI语法。Alexa App将打开此Universal Link和App Link URL以启动自有App。

        • 要处理不同的场景,可以添加多个redirect URL。

        • 如果您的App不支持Universal Link和App Link,你应该有一个redirect URL指向的有效网页。这应该支持相同的过程来接收用户的Amazon授权代码,如上流程设计所述。

        • 请记住为iOS应用程序、Android应用程序和网站添加redirect URL。

    • 使用ASK CLI配置。

      要使用ASK CLI启用App to App Account Linking,请根据以下步骤使用更新帐户链接信息中的子命令:

      • 允许用户在没有帐户链接的情况下启用技能选择YN

      • Authorization URL输入授权服务器的URL,该URL用于常规帐户链接。授权服务器必须接受用户的凭据,对用户进行身份验证,并生成一个授权代码,Alexa App稍后可以传递给您的授权服务器,以检索一个访问令牌,该令牌将用户与您的服务唯一标识。

      • Client ID客户端ID,在您的登录页面输入,将用来识别请求来自您的技能的标识符。

      • Scopes(用逗号分隔)输入表示用户帐户所需的访问权限的字符,例如用户ID。对于智能家居技能,这个是必填项。您最多可以指定15个scopes

      • Domains(用逗号分隔)输入登录页面从中获取内容的其他域列表。您最多可以指定15个域。

      • 授权授予类型选择AUTH_CODE

      • 访问令牌URI输入访问令牌URL,详见URL和相关参数说明

      • Client Secret输入一个凭据,允许Alexa服务使用访问令牌URI进行身份验证。这与Client ID(客户端ID)相结合,以识别来自Alexa的请求。

      • 客户端身份验证方案选择HTTP_BASICREQUEST_BODY_CREDENTIALS

      • (可选)默认访问令牌到期时间(以秒为单位)输入访问令牌有效的时间(以秒为单位)。

      • (可选)交互访问令牌URL输入URI,该URI将使用授权代码调用,这些授权代码可以交换Alexa访问令牌。

      • (可选)App to App Account Linking的redirect URL输入URL和相关参数说明中描述的App redirect URL,App to App Account Linking需要此设置。

    • 使用Alexa skill Management API(SMAPI)配置。

      要使用Alexa skill Management API(SMAPI)来配置App to App Account Linking,请根据此Account linking request进行结构设置。

      以下是使用SMAPI添加redirect URL的请求示例。

      名称

      描述

      类型

      skipOnEnablement

      设置为true,允许用户在不启动帐户链接流的情况下启用该技能。当用户启用该技能时,设置为false,以要求正常的帐户链接流。有关更多信息,请参阅让用户在不链接账户的情况下启用您的技能

      Boolean

      type

      指定OAuth授权授予类型。对于App to App Account Linking,您必须使用AUTH_CODE

      String

      authorizationUrl

      您的授权服务器的URL,该URL必须接受用户的凭据,对用户进行身份验证,并生成授权代码,Alexa App稍后可以传递给您的授权服务器,以检索唯一标识用户与您的服务的访问令牌。

      String

      domains

      您的登录页面从中获取内容的其他域列表。您最多可以指定15个域。

      Array of String

      clientId

      您的登录页面用于识别请求来自您的技能的标识符。

      String

      scopes

      字符串,指示用户帐户所需的访问权限,例如user_id。对于智能家居技能,这个领域是必填项。您最多可以指定15个scopes

      Array of String

      accessTokenUrl

      用于请求授权令牌的URI。仅当typeAUTH_CODE时才需要。

      String

      clientSecret

      您提供的凭据,允许Alexa服务使用访问令牌URI进行身份验证。这与clientId相结合,以识别来自Alexa的请求。

      String

      accessTokenScheme

      使用的身份验证类型。例如HTTP_BASIC,或REQUEST_BODY_CREDENTIALS。对于App to App Account Linking,这是必需的,因为授权授予类型是AUTH_CODE

      String

      redirectUrls

      用Universal Link或App Link来启动自有App,redirectURL必须符合指定的URI语法

      Array of String

      {
         "accountLinkingRequest": {
            "accessTokenScheme": "HTTP_BASIC",
            "accessTokenUrl": "https://api.amazon.com/auth/o2/token/",
            "authorizationUrl": "https://www.amazon.com/ap/oa/",
            "clientId": "yourSMAPIClientId",
            "clientSecret": "yourSMAPIClientSecret",
            "domains": [],
            "redirectUrls": ["yourRedirectURL1","yourRedirectURL2"],
            "scopes": ["profile"],
            "skipOnEnablement": true,
            "type": "AUTH_CODE"
          }
      }

  1. 获取用户的Amazon授权码。

    自有App中需要获取用户的Amazon授权码,以便以后可以将其交换为Amazon访问令牌。Amazon访问令牌将使自有App能够启用技能并完成帐户链接。Amazon授权码的有效期为5分钟。

    通过打开Alexa App URL(对于Alexa App流)或向LWA授权服务器(对于LWA流)提出HTTP GET请求,您可以获得用户的Amazon授权代码。对于iOS和Android,我们强烈建议您实施Alexa App流,并仅将LWA用作后备。

    要获取用户的Amazon授权代码,您需要组装授权请求,然后打开Alexa App URL(如果没有安装Alexa App,则打开LWA fallback URL),并附上URL和相关参数说明中描述的参数。我们强烈建议您在后端服务器中组装这些URL,并将组装的URL传递给您的应用程序。这样,您就可以快速更改参数(如阶段),而无需重建应用程序。

    当用户确认链接请求时,Alexa App会将用户重新定向到应用程序的redirect URL,这是Alexa或LWA fallback URL中的一个参数。redirect URL包括接下来描述的成功或错误参数。如果用户的设备无法使用您提供的redirect URL启动您的应用程序,用户将被发送到其默认浏览器中的同一redirect URL。

    例如,当以下情况下,可能会发生这种情况:

    • 您的应用程序中没有启用Universal Link或App Link。

    • 您的应用程序中没有为您提供的redirect URL启用Universal Link或App Link。

    • 用户设备上的应用程序版本是不支持Universal Link或App Link的旧版本。

在这些情况下,您应该有一个网页(与Universal Link或App Link的URL相同)来接收授权代码。当用户进入您的网页时,他们可能需要登录,以便您可以从您的服务中获得授权代码。

有关可能出现的错误和要向用户显示的相关消息,请参阅获取Amazon授权代码时的错误。

Alexa AppURL的HTTP GET请求的成功响应如果成功,响应是一个redirect URL,其中包括用户的Amazon授权代码和接下来显示的其他参数。要处理这个问题,请参阅苹果关于支持应用程序中通用链接的准则处理安卓应用程序链接的安卓指南

// Example success response from the Alexa app
https://yourRedirectURL?code={Amazon Authorization code}&state={your state}
// Example success response from LWA
https://yourRedirectURL?code={Amazon Authorization code}&scope={permission scope}&state={your state}

地址

  • code:Alexa App或LWA返回的Amazon授权代码。

  • state:您在授权请求中传递的状态。您应该验证状态,以避免跨站点请求伪造。

  • scope:权限范围。LWA returnalexaalexa::skills:account_linking scope。Alexa App不返回范围参数。

有关OAuth2.0授权响应的更多详细信息,请参阅授权响应

Alexa AppURL的HTTP GET请求的错误响应

如果出现错误,响应是redirect URL,接下来显示错误和其他参数。您的应用程序必须显示适当的错误信息(错误页面、UIAlertController等)。有关要显示的错误消息,请参阅获取Amazon授权代码时的错误码

// Example error response
HTTP 200
https://yourRedirectURL?error_description={Error description}&state={your state}&error={OAuth Error Code}

补充说明

  • error:单个ASCII错误代码。可能的值:invalid_request、unauthorized_client、access_denied、unsupported_response_type、invalid_scope、server_error、temporarily_unavailable。

  • error_description:错误描述。

有关OAuth2.0错误响应的更多详细信息,请参阅错误响应

iOS 示例

示例:获取URL(iOS)

在本例中,该应用程序同时获得Alexa AppURL和LWA fallback URL。

import Alamofire
import SwiftyJSON
class AlamofireHelper {
    // Your backend base URL from which you're requesting the URL
    static let BASE_URL = "https://exampleBackEndURL"
    /*! Get Alexa app Universal Link and LWA fallback URL
     @param userAccessToken for your backend server
     @param state maintained between client and server, caller should generate it 
     */
    static func getAlexaAppUrl(userAccessToken:String, state: String) -> DataRequest {
        let alexaAppUrl = "/alexaurl"
        let inputstate = "?state=" + state
        var urlRequest = URLRequest(url: URL(string: BASE_URL + alexaAppUrl + inputstate)!)
        urlRequest.addValue("Bearer " + userAccessToken, forHTTPHeaderField: "Authorization")
        return Alamofire.request(urlRequest)
    }
    private init() {}
}
// Call to your backend to get the authorization request URLs (Alexa app / LWA)
AlamofireHelper.getAlexaAppUrl(userAccessToken: savedSession.accessToken, state: StateHelper.generateState()).responseJSON {
    response in
    guard let status = response.response?.statusCode, 200..<300 ~= status else {
        self.createAlert(title: "Failed to get Alexa URL", message: "Failed to get Alexa URL")
        return
    }
    guard let responseValue = response.result.value else {
        self.createAlert(title: "Invalid response", message: "Invalid response")
        return
    }
    let reponseInJSON = JSON(responseValue)
    guard let companionApp = reponseInJSON["companionAppURL"].string,let lwaFallback = reponseInJSON["LWAFallBackURL"].string else {
        self.createAlert(title: "Error response", message: "Error response")
        return
    }
    guard let companionAppURL = URL(string: companionApp), let lwaFallbackURL = URL(string: lwaFallback) else {
        self.createAlert(title: "Incorrect URL format", message: "Incorrect URL format")
        return
    }
    // The openUniversalLinks code snippet can be found in another example
    self.openUniversalLinks(companionAppURL: companionAppURL, lwaFallbackURL: lwaFallbackURL)
}

示例:打开URL(iOS)

在本例中,该应用程序将打开Alexa AppURL,如果没有安装Alexa App,则打开LWA fallback URL。

注意以下内容:

  • 只有当应用程序(此处为Alexa App)配置为使用universalLinksOnly选项处理通用链接时,URL才会打开。因此,您可以通过关闭表达式completionHandler处理Alexa App无法启动的情况,关闭表达式请参阅Closure Expressions(例如,Alexa App没有安装或版本不支持通用链接)。

  • 如果您的应用程序在使用universalLinksOnly选项时无法启动Alexa App,您的应用程序需要使用LWA获取Amazon授权代码。您可以在iOS 11上使用SFAuthenticationSession或iOS 12.0或更高版本上的ASWebAuthenticationSession。这将获得Safari用户会话和cookie,当用户已经在Safari上登录Amazon.com时,这避免了额外的登录。这将导致您的应用程序显示一个窗口,要求用户同意共享网站信息。该消息的内容由iOS定义。

import SafariServices
// Open the Alexa URL with option universalLinksOnly
// If it doesn't open the Universal Link, open the LWA fallback 
private func openUniversalLinks(companionAppURL: URL, lwaFallbackURL: URL) {
    UIApplication.shared.open(companionAppURL, options: [UIApplication.OpenExternalURLOptionsKey.universalLinksOnly:true]) {
        companionAppLaunched in
        if !companionAppLaunched {
            if #available(iOS 12.0, *) {
                WebSession.initWebAuthenticationSession(authURL: lwaFallbackURL)
            } else if #available(iOS 11.0, *) {
                WebSession.initAuthenticationSession(authURL: lwaFallbackURL)
            } else {
                let safariViewController =  SFSafariViewController(url: lwaFallbackURL)
                self.present(safariViewController, animated: true, completion: nil)}
        }
    }
}
// Web session
class WebSession {
    @available(iOS, introduced: 11.0, deprecated: 12.0)
    private static var authenticationSession: SFAuthenticationSession?
    @available(iOS 12.0, *)
    private static var webAuthenticationSession : ASWebAuthenticationSession?
    private static let CALLBACK_URL_SCHEME = "https://yourAppsUniversalLinkURL"
    @available(iOS, introduced: 11.0, deprecated: 12.0)
    static func initAuthenticationSession(authURL:URL) {
        if let session = authenticationSession {
            session.cancel()
        }
        self.authenticationSession = SFAuthenticationSession.init(url: authURL, callbackURLScheme: CALLBACK_URL_SCHEME, completionHandler:{
            (callBack:URL?, error:Error?) in
            //Callback and error handling
        })
        self.authenticationSession?.start()
    }
    @available(iOS 12.0, *)
    static func initWebAuthenticationSession(authURL:URL) {
        if let session = webAuthenticationSession {
            session.cancel()
        }
        self.webAuthenticationSession = ASWebAuthenticationSession.init(url: authURL, callbackURLScheme: CALLBACK_URL_SCHEME, completionHandler:{
            (callBack:URL?, error:Error?) in
            // Callback and error handling
        })
        self.webAuthenticationSession?.start()
    }
    private init(){}
}

示例:打开LWA URL(iOS)

在网站的情况下打开LWA URL,作为iOS设备上没有安装Alexa App的fallback。

在本例中,该应用程序在视图控制器中打开LWA。

import SafariServices
// Open Login with Amazon in Safari View controller
private func openSafariView(lwaFallbackURL: URL) {
    let safariViewController =  SFSafariViewController(url: lwaFallbackURL)
    self.present(safariViewController, animated: true, completion: nil)}
}

示例:处理通用链接响应(iOS)

在本例中,该应用程序验证并提取Alexa App对其通用链接的调用中的参数。

// Handler for Universal Links in AppDelegate
// Add this method to handle incoming Universal Links
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
        let incomingURL = userActivity.webpageURL else {
            return false
    }
    let handlers : [UniversalLinkHandler] = [AuthResponseHandler(), ErrorResponseHandler()]
    var validatedResponse : ValidatedResponse?
    for handler in handlers {
        if handler.canHandle(incomingURL: incomingURL) {
            validatedResponse = handler.getValidatedResponse()
            let initialViewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "linkingStatusScreen") as! LinkingStatusViewController
            initialViewController.response = validatedResponse
            self.window?.rootViewController = initialViewController
            self.window?.makeKeyAndVisible()
            return true
        }
    }
    return false
}
// Incoming Universal Links handler protocol
protocol UniversalLinkHandler {
    //Validate the incoming URL scheme
    func canHandle(incomingURL:URL)-> Bool
    //Get the validated response
    func getValidatedResponse() -> ValidatedResponse?
}
extension UniversalLinkHandler {
    func validateState(state:String)->Bool {
        return StateHelper.validateState(state: state)
    }
}
// Successfully receiving the Amazon Authorization code
class AuthResponseHandler : UniversalLinkHandler {
    private let AUTH_RESPONSE_PARAMETERS: Set = ["code", "state", "scope"]
    private var validatedResponse: ValidatedResponse?
    func canHandle(incomingURL: URL) -> Bool {
        guard let components = NSURLComponents(url: incomingURL, resolvingAgainstBaseURL: true),
            let queryParameters = components.queryItems else {
                return false
        }
        var validatedParameters : [String: String] = [:]
        for queryParameter in queryParameters {
            //duplicated parameters in URL
            if validatedParameters.keys.contains(queryParameter.name) {
                return false
            }
            //Unrecognized parameters in URL
            if !AUTH_RESPONSE_PARAMETERS.contains(queryParameter.name) {
                return false
            }
            validatedParameters[queryParameter.name] = queryParameter.value
        }
        guard let code = validatedParameters["code"], let state = validatedParameters["state"] else {
            return false
        }
        //validated the state
        if !validateState(state: state) {
            self.validatedResponse = ErrorResponse(url: incomingURL,error: "Invalid state", errorDescription: "The request has invalid state")
            return true
        }
        self.validatedResponse = AuthResponse(url: incomingURL,code: code, state: state, scope: validatedParameters["scope"])
        return true
    }
    func getValidatedResponse() -> ValidatedResponse? {
        return validatedResponse
    }
}
// Error authorization response handler
class ErrorResponseHandler : UniversalLinkHandler {
    private let ERROR_RESPONSE_PARAMETERS: Set = ["error", "error_description", "state"]
    private var validatedResponse: ValidatedResponse?
    func canHandle(incomingURL: URL) -> Bool {
        guard let components = NSURLComponents(url: incomingURL, resolvingAgainstBaseURL: true),
            let queryParameters = components.queryItems else {
                return false
        }
        // Validate all query parameters 
        var validatedParameters : [String: String] = [:]
        for queryParameter in queryParameters {
            // Duplicated parameters in URL
            if validatedParameters.keys.contains(queryParameter.name) {
                return false
            }
            // Unrecognized parameters in URL
            if !ERROR_RESPONSE_PARAMETERS.contains(queryParameter.name) {
                return false
            }
            validatedParameters[queryParameter.name] = queryParameter.value
        }
        guard let error = validatedParameters["error"], let errorDescription = validatedParameters["error_description"], let state = validatedParameters["state"] else {
            return false
        }
        // validate the state
        if !validateState(state: state) {
            self.validatedResponse = ErrorResponse(url: incomingURL,error: "Invalid state", errorDescription: "The request has invalid state")
            return true
        }
        self.validatedResponse = ErrorResponse(url: incomingURL, error: error, errorDescription: errorDescription)
        return true
    }
    func getValidatedResponse() -> ValidatedResponse? {
        return validatedResponse
    }
}
// Validated response
class ValidatedResponse {
    // Universal Link URL that launched the App
    private(set) var url: URL
    init (url: URL) {
        self.url = url
    }
}
// Amazon authorization code response per the OAuth2 specification 
class AuthResponse : ValidatedResponse{
    private(set) var code : String
    private(set) var state: String
    //scope will be returned in Login with Amazon, will not be present in Alexa Companion flow
    private(set) var scope: String?
    init(url:URL, code:String, state:String, scope: String?) {
        self.code = code
        self.state = state
        self.scope = scope
        super.init(url:url)
    }
}
// Error response per the OAuth2 specification 
class ErrorResponse : ValidatedResponse {
    private(set) var error : String
    private(set) var errorDescription: String
    init(url: URL, error:String, errorDescription:String) {
        self.error = error
        self.errorDescription = errorDescription
        super.init(url: url)
    }
}

Android示例

示例:获取URL(安卓)

向您的后端服务提出请求,以获取Alexa App URL和LWA fallback URL。您可以以任何您喜欢的方式实现此请求,例如使用RetroFit或任何其他Android库。

以下代码是一个简单的例子。

fun doAppToApp(){
  val appToAppUrls: AppToAppUrls = yourBackendService.getAppToAppUrls();
  // This function is shown in a different example 
  openAlexaAppToAppUrl(appToAppUrls.alexaAppUrl, appToAppUrls.lwaFallBackUrl)
}
data class AppToAppUrls(
  @SerializedName("alexaAppUrl")
  val alexaAppUrl: String,
  @SerializedName("lwaFallBackUrl")
  val lwaFallBackUrl : String
)
interface BackendService {
  fun getAppToAppUrls() : AppToAppUrls
}

示例:打开URL(Android)

在本例中,该应用程序将打开Alexa AppURL,如果没有安装Alexa App,则打开LWA fallback URL。

注意以下内容:

  • 本示例使用PackageManager验证Alexa App是否已安装,并验证已安装的Alexa App版本是否可以处理App to App Account Linking。

  • 如果Alexa App没有安装或无法处理App to App Account Linking,您的应用程序必须使用LWA流程获得Amazon授权代码。有关LWA fallback URL格式,请参阅下文URL和相关参数说明

  • 此示例使用AndroidACTIONACTION_VIEW意图打开URL。应用程序到应用程序的URL将在Alexa App中打开,LWA fallback URL将在用户的默认浏览器中打开。

private fun openAlexaAppToAppUrl(alexaAppUrl: String, lwaFallbackUrl: String){
    if (AlexaAppUtil.isAlexaAppSupportAppLink(fragmentContext!!)) {
        val alexaAppToAppIntent = getAppToAppIntent(alexaAppUrl);
        startActivity(alexaAppToAppIntent)
    } else {
        val lwaAppToAppIntent = getAppToAppIntent(lwaFallbackUrl);
        startActivity(lwaAppToAppIntent)
    }
}
private fun getAppToAppIntent(appToAppUrl: String): Intent {
    return Intent(Intent.ACTION_VIEW, Uri.parse(appToAppUrl))
}
/**
 * Utility to check if the Alexa app is installed and supports app-to-app.
 */
object AlexaAppUtil {
    private const val ALEXA_PACKAGE_NAME = "com.amazon.dee.app"
    private const val ALEXA_APP_TARGET_ACTIVITY_NAME = "com.amazon.dee.app.ui.main.MainActivity"
    private const val REQUIRED_MINIMUM_VERSION_CODE = 866607211 
    /**
     * Check if the Alexa app is installed and supports App Links.
     *
     * @param context Application context.
     */
    @JvmStatic
    fun doesAlexaAppSupportAppToApp(context: Context): Boolean {
        try {
            val packageManager: PackageManager = context.packageManager
            val packageInfo = packageManager.getPackageInfo(ALEXA_PACKAGE_NAME, 0)
            return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                packageInfo.longVersionCode > REQUIRED_MINIMUM_VERSION_CODE
            } else {
                packageInfo != null
            }
        } catch (e: PackageManager.NameNotFoundException) {
            // The Alexa App is not installed
            return false
        }
    }
}

示例:处理应用程序链接响应(Android)

在本例中,该应用程序验证并提取Alexa App对其应用程序链接的调用中的参数。

/**
 * App-to-app result page landing activity.
 */
class AppToAppResultPageActivity : AppCompatActivity() {
  private lateinit var statusText: TextView
  private lateinit var goBackButton: Button
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    val appLinkData = intent.data
    setContentView(R.layout.app_to_app_result)
    initializeComponents()
    statusText.text = "Linking your account"
    // Send the data returned from the Alexa App / LWA to your backend 
    // to call the Skill Activation API to link the accounts 
    val accountLinking = linkAccountInBackend(appLinkData)
    displayAccountLinkingStatus(result); 
  }
  private fun initializeComponents() {
    statusText = findViewById(R.id.text_status)
    goBackButton = findViewById(R.id.btn_goback)
    goBackButton.setOnClickListener {
      val intent = Intent(this, MainActivity::class.java)
      startActivity(intent)
    }
  }
}

状态生成和验证

示例:State Helper

在本例中,应用程序生成并验证请求和响应之间的状态。您必须使用状态验证传入的请求,以防止跨站点请求伪造,请参阅Cross-Site Request Forgery

import Foundation
import Security
class StateHelper {
    private static let STATE_VALID_FOR = 3600
    // Base64 (iOS + TimeStamp + Secure Random UUID)
    private static let NUMBER_OF_PARAMETER = 3
    static func generateState(session: Session)-> String {
        var buffer = Data(count: 30)
        let _ = buffer.withUnsafeMutableBytes {
            SecRandomCopyBytes(kSecRandomDefault, 30, $0)
        }
        let state = ("iOS." + String(Int64(Date().timeIntervalSince1970)) + "." + buffer.base64EncodedString()).encodeToURISafeBase64()
        // store state in session manager for future validation
        session.state = state
        LocalSessionManager.saveSession(session: session)
        return state
    }
    static func validateState(state: String) -> Bool {
        guard let originalState = LocalSessionManager.loadSession()?.state  else {
            return false
        }
        if state != originalState {
            return false
        }
        if let timeStampString = originalState.decodeFromURISafeBase64()?.split(separator: "."), timeStampString.count == NUMBER_OF_PARAMETER, let timeStamp = Int64(timeStampString[1]){
            if Int64(Date().timeIntervalSince1970) - timeStamp <= STATE_VALID_FOR {
                return true
            }
        }
        if let session = LocalSessionManager.loadSession() {
            session.state = nil
            LocalSessionManager.saveSession(session: session)
        }
        return false
    }
    private init() {}
}
extension String {
    func decodeFromURISafeBase64() -> String? {
        let base64String = String(self.map {
            // replace unsafe characters 
            character in
            if character == "." {
                return  "+"
            } else if character == "_" {
                return "/"
            } else if character == "-" {
                return "="
            }
            return character
        })
        guard let data = Data(base64Encoded: base64String) else {
            return nil
        }
        return String(data: data, encoding: .utf8)
    }
    func encodeToURISafeBase64() -> String {
        return String(Data(self.utf8).base64EncodedString().map{
            // replace back unsafe characters 
            character in
            if character == "+" {
                return  "."
            } else if character == "/" {
                return "_"
            } else if character == "=" {
                return "-"
            }
            return character
        })
    }
}

  1. 将Amazon授权码交换为Amazon访问令牌。

    在自有App中收到用户的Amazon授权码后,您需要使用它来获取Amazon访问令牌。您需要访问令牌来调用Alexa服务以启用技能并完成帐户链接。

    使用下图所示格式提出HTTP POST请求。有关可能出现的错误和要向用户显示的相关消息,请参阅将Amazon授权代码交换为Amazon访问令牌时的错误码

    请求参数

    POST /auth/o2/token HTTP/1.1
    Host: api.amazon.com
    Content-Type: application/x-www-form-urlencoded;charset=UTF-8
    grant_type=authorization_code
    &code=amazon authorization code
    &client_id=yourClientId
    &client_secret=yourClientSecret
    &redirect_uri=yourRedirectUri

    返回参数

    HTTP 200
    Content-Type: application/json
    {
      "access_token": "AmazonAccessToken",
      "expires_in": "3600",
      "refresh_token": "YourRefreshToken",
      "token_type": "bearer"
    }

    有关在LWA的情况下获取访问令牌的详细信息,请参阅访问令牌请求访问令牌响应访问令牌错误

    示例:获取用户的Amazon访问令牌

    在本例中,该应用程序将用户的Amazon授权代码交换为Amazon访问令牌。

    const axios = require('axios');
    // Amazon OAuth token request in the backend
    const OAuthRequests = (function () {
        const header = {
            "headers": {
                "Content-Type": "application/json"
            }
        };
        const accessTokenBody = function (amazonAuthCode, state) {
            return {
                "grant_type": "authorization_code",
                "code": amazonAuthCode,
                "redirect_uri": config.skillConfig.redirect_uri,
                "client_id": config.skillConfig.client_id,
                "client_secret": config.skillConfig.client_secret
            };
        };
        return {
            accessTokenRequest: (authcode, state) => {
                return {
                    header: header,
                    body: accessTokenBody(authcode, state)
                }
            }
        }
    })();
    // Request the Amazon access/refresh tokens from the Amazon OAuth token server
    function getAccessTokenByAuthcode(amazonAuthCode, state) {
        const accessTokenRequest = OAuthRequests.accessTokenRequest(amazonAuthCode, state);
        return axios.post(config.endpoints.oauthEndpoint, accessTokenRequest.body, accessTokenRequest.header);
    }
  2. 启用Skill并完成帐户链接。

    当您的自有App具有用户的Amazon访问令牌,您的后端服务器可以调用Alexa Skill激活API来启用该技能并完成帐户链接。

    使用以下URL格式向Alexa Skill激活API提出请求:[BASEURL]/v1/users/~current/skills/{yourSkillId}/enablement,其中[BASE URL]取决于用户所在的地区:https://api.amazonalexa.comhttps://api.eu.amazonalexa.comhttps://api.fe.amazonalexa.com。您使用的安全提供商必须授权从应用程序对应用程序后端服务器的调用,并提供服务器端API,以获取当前登录您应用程序的用户授权代码。(也就是说,它需要在您的服务中提供用户帐户的授权代码。)Alexa将使用此代码交换用户访问令牌,以访问用户的资源。有关可能出现的错误和要向用户显示的相关消息,请参阅Errors when calling the Alexa Skill Activation API。有关Alexa技能激活API的详细信息,请参阅Alexa Skill激活API

    说明
    • 如果您使用LWA作为安全提供商,请记住,您用于获取userauthCode的LWA Client ID与Alexa开发者控制台在配置应用程序到应用程序帐户链接技能时显示的Alexa客户端ID不同。您无法使用用于获取Amazon访问令牌(步骤5)的Alexa客户端ID为您的用户生成anauthCode。要生成新的userauthCode,请使用与LWA安全配置文件关联的LWA Client ID。

    • 如果您拥有智能家居技能,并且已为该技能启用了发送Alexa Events权限,则您的Lambda函数必须处理AcceptGrant指令。否则,当用户尝试启用您的技能时,帐户链接失败。有关详细信息,请参阅使用权限将客户身份验证到Alexa。请注意,一般来说,将事件发送到事件网关的工作方式仍然像今天一样工作。也就是说,在启用技能后,该技能可以从指令中获得访问令牌。

    要启用该技能,请提出以下请求:

    请求参数

    POST /v1/users/~current/skills/{skillId}/enablement HTTP/1.1
    Host: api.amazonalexa.com, api.eu.amazonalexa.com, api.fe.amazonalexa.com
    Content-Type: application/json
    Authorization: "Bearer {Amazon Access Token}"
    {
        "stage": "skill stage",
        "accountLinkRequest": {
          "redirectUri": "https://yourRedirectURI",
          "authCode": "Your user's authorization code from your authorization server",
          "type": "AUTH_CODE"
        }
    }

    在下一个显示的请求中,根据用户所在地区将HOST设置为以下之一:api.amazonalexa.com、api.eu.amazonalexa.com,或api.fe.amazonalexa.com

    成功响应

    HTTP 201
    Content-Type: application/json
    {
      "skill": {
        "stage": "your skill stage",
        "id": "your skill id"
      },
      "user": {
        "id": "User ID"
      },
      "accountLink": {
        "status": "LINKED"
      },
      "status": "ENABLED"
    }

    请注意,您可以通过向Alexa技能激活API提出ADELETE请求来禁用该技能并取消用户帐户的链接。

    示例:启用技能并完成帐户链接

    在本例中,该应用程序启用了该技能并完成了帐户链接。

    const _ = require('lodash');
    const axios = require('axios');
    const {config} = require('../config/skillconfig');
    const {getAccessTokenByAuthcode} = require('./oauthrequests');
    // Request to the Alexa Skill Activation API in the backend
    const AlexaServiceRequest = (() => {
        const header = (amazonAccessToken) => {
            return {
                "headers": {
                    "Content-Type": "application/json",
                    "Authorization": "Bearer " + amazonAccessToken
                }
            };
        };
        const body = (userAuthCode) => {
            return {
                "stage": config.skillConfig.stage,
                "accountLinkRequest": {
                    "redirectUri": config.skillConfig.redirect_uri,
                    "authCode": userAuthCode,
                    "type": "AUTH_CODE"
                }
            }
        }
        return {
            createEnablement: (amazonAccessToken, userAuthCode) => {
                return {
                    header: header(amazonAccessToken),
                    body: body(userAuthCode)
                }
            }
        }
    })();
    function createEnablementWithAccountLink(amazonAuthCode, userAuthcode, state) {
        // Exchange Amazon OAuth Code for Amazon Access Token
        const tokenPromise = getAccessTokenByAuthcode(amazonAuthCode, state);
        return tokenPromise.then((res) => {
            if (!_.has(res, 'data.access_token')) {
                throw Error("Amazon AccessToken is invalid");
            }
            console.log(res);
            const amazonAccessToken = res.data.access_token;
            // Call the Alexa Skill Activation API to create enablement
            const createEnablementRequest = AlexaServiceRequest.createEnablement(amazonAccessToken, userAuthcode);
            return sendPostRequests(config.endpoints.alexaServiceEndpoints, createEnablementRequest.body, createEnablementRequest.header);
        });
    }
    // Post enablement to the Alexa Skill Activation API
    function sendPostRequests(endpoints, body, header) {
        var alexaServicePromises = []
        // Post for each Alexa Skill Activation API regional endpoint for the user
        endpoints.forEach((endpoint)=> {
            alexaServicePromises.push(axios.post(endpoint, body, header));
        });
        return new Promise((resolve, reject)=> {
            var failures = 0;
            alexaServicePromises.forEach((promise)=> {
                promise.then((res)=> {
                    if (res.status == 201) {
                        resolve(res.data);
                    } else {
                        if (++failures == alexaServicePromises.length) {
                            reject(res.data);
                        }
                    }
                }).catch((err)=> {
                    if (++failures == alexaServicePromises.length) {
                        reject(err.data);
                    }
                })
            })
        });
    }
  3. 在自有App中显示Account Linking状态。

    通过在自有App中显示帐户链接状态,例如在"用户体验和错误"中的屏幕截图中,让用户了解帐户链接状态。您可以使用不同的用户界面组件(例如表或页面视图)来显示帐户链接状态。

    示例:显示帐户链接状态

    在本例中,该应用程序使用UIViewController显示帐户链接状态。

    1. 在Xcode IDE中,前往主故事板添加新的视图控制器。此视图控制器的故事板ID是"linkingStatusScreen"。

    2. 添加一个新的Cocoa Touch类文件,选择UIViewController的子类,并在身份检查器中将您的类文件与UIViewController连接起来。

    3. 添加标题、显示帐户链接状态的UILabel状态、关闭此页面的UIButton和UIActivityIndicatorView,以便在应用程序调用Alexa服务时显示加载动画。

      import UIKit
      class LinkingStatusViewController: UIViewController {
          @IBOutlet weak var header: UILabel!
          @IBOutlet weak var status: UILabel!
          @IBOutlet weak var closeButton: UIButton!
          @IBOutlet weak var indicator: UIActivityIndicatorView!
          var response : ValidatedResponse?
          override func viewDidLoad() {
              super.viewDidLoad()
              if let res = self.response {
                  passValidatedResponse(validatedResponse: res)
              }
          }
          private func updateView(header:String, status:String, animating: Bool,closeButton:Bool) {
              if animating {
                  self.indicator?.startAnimating()
              } else {
                  self.indicator?.stopAnimating()
              }
              self.indicator?.isHidden = !animating
              self.header?.text = header
              self.status?.text = status
              self.closeButton.isEnabled = closeButton
              if closeButton {
                  self.closeButton.backgroundColor = #colorLiteral(red: 0.1960784314, green: 0.6039215686, blue: 0.8392156863, alpha: 1)
              } else {
                  self.closeButton.backgroundColor = #colorLiteral(red: 0.6000000238, green: 0.6000000238, blue: 0.6000000238, alpha: 1)
              }
          }
      }
      extension LinkingStatusViewController {
          func passValidatedResponse(validatedResponse: ValidatedResponse) {
              if validatedResponse is AuthResponse {
                  let response: AuthResponse = validatedResponse as! AuthResponse;
                  updateView(header: "Status", status: "Loading", animating: true, closeButton: false)
                  usingAuthCodeToGetAccessToken(amazonAuthCode: response.code)
              } else if (validatedResponse is ErrorResponse) {
                  let response: ErrorResponse = validatedResponse as! ErrorResponse;
                  updateView(header: response.error, status: response.errorDescription, animating: false, closeButton: true)
              } else {
                  updateView(header: "Unknown request", status: validatedResponse.url.absoluteString, animating: false, closeButton: true)
              }
          }
          // Complete account linking
          private func usingAuthCodeToGetAccessToken(amazonAuthCode: String) {
              guard let session = LocalSessionManager.loadSession(), session.sessionValid  else {
                  updateView(header: "Status", status: "User not login", animating: false, closeButton: true)
                  return;
              }
              AlamofireHelper.completeAccountLinking(userAccessToken: LocalSessionManager.loadSession()!.accessToken, amazonAuthCode: amazonAuthCode, state: StateHelper.generateState(session: session)).responseJSON(queue: DispatchQueue.global(qos: .userInitiated), options: []) {
                  response in
                  if let status = response.response?.statusCode, 200..<300 ~= status {
                      DispatchQueue.main.sync {
                          self.updateView(header: "Status", status: "Account Linking Succeed", animating: false, closeButton: true)
                      }
                      return
                  } else {
                      DispatchQueue.main.sync {
                          self.updateView(header: "Failed", status: "Account Linking failed", animating: false, closeButton: true)
                      }
                  }
              }
          }
      }
      // Open the account linking status page
      let initialViewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "linkingStatusScreen") as! LinkingStatusViewController
      initialViewController.response = validatedResponseself.window?.rootViewController = initialViewController
      self.window?.makeKeyAndVisible()
  4. 使用Skill中的访问令牌。

    用户成功启用您的Skill并将Alexa链接到您的服务后,发送到您的Skill的请求包括用户的访问令牌。您的Skill代码需要从请求中获取访问令牌,验证它,并使用它从资源服务器检索必要的用户信息。有关如何验证和使用访问令牌的详细信息,请参阅技能类型:

以云智能开发为例(安卓、IOS)

云智能Android App部分

App-Alexa-App

  1. 获取Alexa App-To-App参数。

    调用云端api:/living/voice/alexa/apptoapp/config/get 获取alex 相关配置。

    返回的实体对象:

    public class AlexaApptoappConfigGetDTO {
        private String alexaAppUrl;
        private String lwaFallbackUrl;
    
  2. 拉起Alexa。

     private void openAlexaAppToAppUrl(String alexaAppUrl, String lwaFallbackUrl) {
            Intent alexaAppToAppIntent;
            if (doesAlexaAppSupportAppToApp(getApplicationContext())) {
                alexaAppToAppIntent = getAppToAppIntent(alexaAppUrl);
            } else {
                alexaAppToAppIntent = getAppToAppIntent(lwaFallbackUrl);
            }
            startActivity(alexaAppToAppIntent);
            finish();
        }
    private Intent getAppToAppIntent(String appToAppUrl) {
            Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(appToAppUrl));
            return intent;
        }
  3. 接收Alexa的技能绑定结果。

    <activity
                android:name=".activity.AuthActivity"
                android:launchMode="singleTask"
                android:screenOrientation="portrait">
    
                <!--Amazon Alexa App-->
                <intent-filter
                    android:autoVerify="true"
                    tools:ignore="UnusedAttribute">
    
                    <action android:name="android.intent.action.VIEW" />
    
                    <category android:name="android.intent.category.DEFAULT" />
                    <category android:name="android.intent.category.BROWSABLE" />
    
                    <data
                        android:host="yourHost"
                        android:scheme="yourScheme"
                        android:pathPrefix="yourPathPrefix" />
                </intent-filter>
            </activity>
      private void handleIntent(@NonNull Intent intent) {
            ALog.d(TAG, "handleIntent: ");
            // amazon alexa 回调回来
            if (intent.getData() == null) return;
            String appLinkData = intent.getData().toString();
            ILog.d(TAG, "appLinkData:" + appLinkData);
            // 从alexa回调回来,去云端鉴权,这个设置一个标签,以防当前云智能账号被挤掉的情况发生。
    
            String domain = "https://open-living.iot.aliyun.com";
            if (GlobalConfig.API_ENV_PRE.equals(GlobalConfig.getInstance().getApiEnv())) {
                domain = "https://open-living-beta.iot.aliyun.com";
            }
            if (appLinkData.startsWith(domain) && !appLinkData.contains("error=") && !appLinkData.contains("error_description=")) {
                if (null == mHandler) {
                    mHandler = new TmallGenieHandler(this);
                }
                // 链接回调
                parsingUrl(appLinkData);
            }
        }
    
    private void parsingUrl(String url) {
            String redirect_uri;
            String code = "";
            String state = "";
    
            url = url.trim();
    
            String[] urlParts = url.split("\\?");
            redirect_uri = urlParts[0];
            if (urlParts.length > 1) {
                String[] params = urlParts[1].split("&");
                Map<String, String> map = new HashMap<>();
                for (String param : params) {
                    String[] keyValue = param.split("=");
                    if (keyValue.length > 1) {
                        map.put(keyValue[0], keyValue[1]);
                    }
                }
                state = map.get("state");
                code = map.get("code");
            }
            ILog.e(TAG, " redirect_uri = " + redirect_uri + ",state = " + state, ",code = " + code);
       
    
        }
  4. 云端查询技能是否绑定成功。

    public void queryAlexaApptoappLaunchGet(String state, String redirectUri, String code) {
            ILog.d("TmallGenieBusiness", "queryAlexaApptoappLaunchGet");
            if (null == mIoTAPIClient) {
                return;
            }
    
            Map<String, Object> params = new HashMap<>();
            params.put("state", state);
            params.put("redirectUri", redirectUri);
            params.put("code", code);
            IoTRequest request = buildRequest(params, "/living/voice/alexa/apptoapp/launch");
    
            ILog.d("TmallGenieBusiness", "requestMap:" + JSON.toJSONString(params));
    
            IoTAPIClient mIoTAPIClient = new IoTAPIClientFactory().getClient();
            mIoTAPIClient.send(request, mListener);
        }

Alexa-App-Alexa

  1. 接收Alexa的认证请求。

    <activity
                android:name=".activity.AuthActivity"
                android:launchMode="singleTask"
                android:screenOrientation="portrait">
    
                <!--Amazon Alexa App-->
                <intent-filter
                    android:autoVerify="true"
                    tools:ignore="UnusedAttribute">
    
                    <action android:name="android.intent.action.VIEW" />
    
                    <category android:name="android.intent.category.DEFAULT" />
                    <category android:name="android.intent.category.BROWSABLE" />
    
                    <data
                        android:host="yourHost"
                        android:scheme="yourScheme"
                        android:pathPrefix="yourPathPrefix" />
                </intent-filter>
            </activity>

    private void handleIntent(@NonNull Intent intent) {
            needKeepUserClick = false;
            ALog.d(TAG, "action: " + intent.getAction());
            ALog.d(TAG, "data: " + intent.getData());
            String clientId = null;
            String state = null;
            final ArrayList<String> scopeList = new ArrayList<>();
            String redirectUri = null;
    
            Uri appLinkData = intent.getData();
            if (appLinkData == null) {
                Log.w(TAG, "handleIntent: ", new Throwable("Cloud Intelligence: empty app link data"));
                finish();
                return;
            }
    
    
            if (appLinkData.getPathSegments().contains("oauth2") || appLinkData.getPathSegments().contains("alexa")) {
                if (appLinkData.getPathSegments().contains("alexa")) {
                    needKeepUserClick = true;
                }
               
                mScope = Scope.Alexa;
                clientId = appLinkData.getQueryParameter("client_id");
                state = appLinkData.getQueryParameter("state");
                String _scope = appLinkData.getQueryParameter("scope");
                if (_scope != null) {
                    scopeList.add(_scope);
                }
                redirectUri = appLinkData.getQueryParameter("redirect_uri");
            } else {
                Log.w(TAG, "handleIntent: ", new Throwable("Cloud Intelligence: unknown path segments"));
            }
    
    
            if (mScope == null) {
                Log.w(TAG, "handleIntent: ", new Throwable("Cloud Intelligence: wrong scope"));
                finish();
                return;
            }
    
    
            String finalClientId = clientId;
            String finalState = state;
            String finalRedirectUri = redirectUri;
        
  2. 去云端获取code。

        private void accountAuth2CodeGet(@NonNull String clientId, @NonNull String state, @NonNull String redirectUri, @Nullable List<String> scope) {
            showLoading();
            JSONObject params = new JSONObject();
            params.put("clientId", clientId);
            params.put("state", state);
            params.put("redirectUri", redirectUri);
            if (scope != null) {
                params.put("scope", scope);
            }
    
            IoTRequestBuilder builder = new IoTRequestBuilder().setAuthType(MineConstants.APICLIENT_IOTAUTH).setApiVersion(LIVING_ACCOUNT_OAUTH2_CODE_GET_VERSION);
            IoTRequest ioTRequest = builder
                    .setPath("/living/account/oauth2/code/get")
                    .setParams(params.getInnerMap())
                    .setScheme(Scheme.HTTPS)
                    .build();
    
    
            new IoTAPIClientFactory().getClient().send(ioTRequest, new IoTCallback() {
                @Override
                public void onFailure(IoTRequest request, Exception e) {
                    ALog.w(TAG, "onFailure: " + (e == null ? "" : e.getMessage()));
                    String message = e != null ? e.getMessage() : null;
                    if (message == null) {
                        message = e != null ? e.getLocalizedMessage() : null;
                    }
                    onAuthFailed(message, redirectUri, state);
                }
    
                @Override
                public void onResponse(IoTRequest request, IoTResponse response) {
                    if (response != null && response.getData() != null && response.getCode() == 200) {
                        ALog.d(TAG, "onResponse succeed");
                        onAuthSucceed(response.getData().toString(), redirectUri, state);
                    } else {
                        String message = response != null ? response.getMessage() : null;
                        if (message == null) {
                            message = response != null ? response.getLocalizedMsg() : null;
                        }
                        ALog.d(TAG, "onResponse error; " + message);
                        onAuthFailed(message, redirectUri, state);
                    }
                }
            });
    
        }

    云端鉴权成功。

    private void onAuthSucceed(@NonNull String code, @NonNull String redirectUri, @NonNull String state) {
            Intent intent = new Intent();
            ThreadPool.MainThreadHandler.getInstance().post(() -> {
                if (isFinishing() || isDestroyed()) return;
                dismissLoading();
                switch (mScope) {
                    case Alexa:
                        intent.setAction(Intent.ACTION_VIEW);
                        intent.setData(getAlexaSuccessfulUri(redirectUri, code, state));
                        try {
                            if (intent.resolveActivity(AApplication.getInstance().getPackageManager()) != null) {
                                startActivity(intent);
                            } else {
                                ALog.d(TAG, "not systemAction->" + intent.getAction());
                            }
                        } catch (Exception e) {
                            e.printStackTrace();
                        }
    
                        break;
                    default:
                        break;
                }
                finish();
            });
        }

    云端鉴权失败。

    private void onAuthSucceed(@NonNull String code, @NonNull String redirectUri, @NonNull String state) {
            Intent intent = new Intent();
            ThreadPool.MainThreadHandler.getInstance().post(() -> {
                if (isFinishing() || isDestroyed()) return;
                dismissLoading();
                switch (mScope) {
                    case Alexa:
                        intent.setAction(Intent.ACTION_VIEW);
                        intent.setData(getAlexaSuccessfulUri(redirectUri, code, state));
                        try {
                            if (intent.resolveActivity(AApplication.getInstance().getPackageManager()) != null) {
                                startActivity(intent);
                            } else {
                                ALog.d(TAG, "not systemAction->" + intent.getAction());
                            }
                        } catch (Exception e) {
                            e.printStackTrace();
                        }
    
                        break;
                    default:
                        break;
                }
                finish();
            });
        }

云智能iOS App部分

开发步骤-云智能iOS App部分

建议开发使用以下库和语言提供iOS应用(9.0或更高版本):

  1. 在云智能iOS App中显示帐户链接选项。

    云智能iOS App需要有一个启动页面,用户可以从中启动技能启用和帐户链接。此页面应显示用户可以通过将其帐户链接到Alexa来做什么,例如:通过语音订购智能彩灯。

    您允许用户启动帐户链接的方式取决于您希望为用户创建的体验。您可以在多个位置向用户提供帐户链接:例如,您可以在用户注册云智能iOS App账号后为其提供启用帐户链接的选项,您可以在App内设置页面的"链接到Alexa"页面上为其提供按钮,等等。在任何情况下,请务必解释与Alexa链接帐户的方便有效之处。

    将App to App Account Linking按钮添加到iOS应用内,请执行以下操作:

    1. 以使用Xcode IDE为例,打开storyboard。

    2. 将UIbutton添加到视图控制器,并将其命名为"Link your account with Alexa"。考虑使用一个名字来表示用户通过链接他们的帐户将得到的好处,例如"用Alexa听你的音乐","订购一个懂你的智能烤箱"。

    3. 在Xcode IDE的左上角,单击"Show the assistant editor"。

    4. 通过将按钮拖动到代码编辑器,为按钮创建操作连接。

  2. 为云智能iOS App启用Universal Link(iOS)。

    要允许Alexa App将用户重新跳转向到云智能iOS App,您必须为iOS应用程序启用Universal Link。

    说明
    • Universal Link和App Link必须符合指定的URI语法。记住您添加的域和路径,因为在Alexa开发者控制台中将redirect URL添加到Skill详细信息时需要它们。

    • 要启用Universal Link,请查看iOS链接开发文档中的示例及说明进行操作。

  3. 配置使用App to App Account Linking的Skill。

    您可以使用Alexa Developer console(Alexa开发者控制台)、Alexa Skills Kit命令行界面(ASK CLI)或Alexa skill Management API(SMAPI)来配置您的Skill(技能)。在此配置过程中,您可以指定云智能iOS App的redirect URL、access token URL等内容。

    • 使用Alexa Developer console配置。

      1. 登录Alexa开发者控制台

      2. 在列表中找到你的Skill。在你的Skill下操作,选择编辑。

      3. 在左侧,单击TOOLS,然后单击Account Link

      4. 如果您以前为您的Skill配置了Account Link,请启用"Allow users to link their account to your skill from within your application or website"。

      5. 对于授权授予类型,如果尚未选择授权代码授予,请选择授权代码授予。App to App Account Linking仅支持授权代码授予。

      6. 对于其他设置,请使用配置Account Link中描述的设置,并在填写名为"redirect URL"的字段时注意以下附加说明:

        • 对于redirect URL,请添加URL和相关参数说明中描述的redirect URL。

        • redirect URL必须符合指定的URI语法。Alexa App将打开此Universal Link和App Link URL以启动云智能iOS App。

        • 要处理不同的场景,可以添加多个redirect URL。

        • 如果您的App不支持Universal Link和App Link,你应该有一个redirect URL指向的有效网页。这应该支持相同的过程来接收用户的Amazon授权代码,如上流程设计所述。

        • 请记住为iOS应用程序、Android应用程序和网站添加redirect URL。

    • 使用ASK CLI配置。

      要使用ASK CLI启用App to App Account Linking,请根据以下步骤使用更新帐户链接信息中的子命令:

      • 允许用户在没有帐户链接的情况下启用技能选择YN

      • Authorization URL输入授权服务器的URL,该URL用于常规帐户链接。授权服务器必须接受用户的凭据,对用户进行身份验证,并生成一个授权代码,Alexa App稍后可以传递给您的授权服务器,以检索一个访问令牌,该令牌将用户与您的服务唯一标识。

      • Client ID客户端ID,在您的登录页面输入,将用来识别请求来自您的技能的标识符。

      • Scopes(用逗号分隔)输入表示用户帐户所需的访问权限的字符,例如用户ID。对于智能家居技能,这个是必填项。您最多可以指定15个scopes

      • Domains(用逗号分隔)输入登录页面从中获取内容的其他域列表。您最多可以指定15个域。

      • 授权授予类型选择AUTH_CODE

      • 访问令牌URI输入访问令牌URL,详见URL和相关参数说明

      • Client Secret输入一个凭据,允许Alexa服务使用访问令牌URI进行身份验证。这与Client ID(客户端ID)相结合,以识别来自Alexa的请求。

      • 客户端身份验证方案选择HTTP_BASICREQUEST_BODY_CREDENTIALS

      • (可选)默认访问令牌到期时间(以秒为单位)输入访问令牌有效的时间(以秒为单位)。

      • (可选)交互访问令牌URL输入URI,该URI将使用授权代码调用,这些授权代码可以交换Alexa访问令牌。

      • (可选)App to App Account Linking的redirect URL输入URL和相关参数说明中描述的App redirect URL,App to App Account Linking需要此设置。

    • 使用Alexa skill Management API(SMAPI)配置。

      要使用Alexa skill Management API(SMAPI)来配置App to App Account Linking,请根据此Account linking request进行结构设置。

      以下是使用SMAPI添加redirect URL的请求示例。

      名称

      描述

      类型

      skipOnEnablement

      设置为true,允许用户在不启动帐户链接流的情况下启用该能。当用户启用该技能时,设置为false,以要求正常的帐户链接流。有关更多信息,请参阅让用户在不链接账户的情况下启用您的技能

      Boolean

      type

      指定OAuth授权授予类型。对于App to App Account Linking,您必须使用AUTH_CODE

      String

      authorizationUrl

      您的授权服务器的URL,该URL必须接受用户的凭据,对用户进行身份验证,并生成授权代码,Alexa App稍后可以传递给您的授权服务器,以检索唯一标识用户与您的服务的访问令牌。

      String

      domains

      您的登录页面从中获取内容的其他域列表。您最多可以指定15个域。

      Array of String

      clientId

      您的登录页面用于识别请求来自您的技能的标识符。

      String

      scopes

      字符串,指示用户帐户所需的访问权限,例如user_id。对于智能家居技能,这个领域是必填项。您最多可以指定15个scopes

      Array of String

      accessTokenUrl

      用于请求授权令牌的URI。仅当typeAUTH_CODE时才需要。

      String

      clientSecret

      您提供的凭据,允许Alexa服务使用访问令牌URI进行身份验证。这与clientId相结合,以识别来自Alexa的请求。

      String

      accessTokenScheme

      使用的身份验证类型。例如HTTP_BASIC,或REQUEST_BODY_CREDENTIALS。对于App to App Account Linking,这是必需的,因为授权授予类型是AUTH_CODE

      String

      redirectUrls

      用Universal Link或App Link来启动云智能iOS App,redirectURL必须符合指定的URI语法

      Array of String

      {
         "accountLinkingRequest": {
            "accessTokenScheme": "HTTP_BASIC",
            "accessTokenUrl": "https://api.amazon.com/auth/o2/token/",
            "authorizationUrl": "https://www.amazon.com/ap/oa/",
            "clientId": "yourSMAPIClientId",
            "clientSecret": "yourSMAPIClientSecret",
            "domains": [],
            "redirectUrls": ["yourRedirectURL1","yourRedirectURL2"],
            "scopes": ["profile"],
            "skipOnEnablement": true,
            "type": "AUTH_CODE"
          }
      }
  4. 获取用户的Amazon授权码。

    云智能iOS App中需要获取用户的Amazon授权码,以便以后可以将其交换为Amazon访问令牌。Amazon访问令牌将使云智能iOS App能够启用技能并完成帐户链接。Amazon授权码的有效期为5分钟。

    通过打开Alexa App URL(对于Alexa App流)或向LWA授权服务器(对于LWA流)提出HTTP GET请求,您可以获得用户的Amazon授权代码。对于iOS和Android,我们强烈建议您实施Alexa App流,并仅将LWA用作后备。

    要获取用户的Amazon授权代码,您需要组装授权请求,然后打开Alexa App URL(如果没有安装Alexa App,则打开LWA fallback URL),并附上URL和相关参数说明中描述的参数。我们强烈建议您在后端服务器中组装这些URL,并将组装的URL传递给您的应用程序。这样,您就可以快速更改参数(如阶段),而无需重建应用程序。

    当用户确认链接请求时,Alexa App会将用户重新定向到应用程序的redirect URL,这是Alexa或LWA fallback URL中的一个参数。redirect URL包括接下来描述的成功或错误参数。

    如果用户的设备无法使用您提供的redirect URL启动您的应用程序,用户将被发送到其默认浏览器中的同一redirect URL。例如,当以下情况下,可能会发生这种情况:

    • 您的应用程序中没有启用Universal Link或App Link。

    • 您的应用程序中没有为您提供的redirect URL启用Universal Link或App Link。

    • 用户设备上的应用程序版本是不支持Universal Link或App Link的旧版本。

    在这些情况下,您应该有一个网页(与Universal Link或App Link的URL相同)来接收授权代码。当用户进入您的网页时,他们可能需要登录,以便您可以从您的服务中获得授权代码。

    有关可能出现的错误和要向用户显示的相关消息,请参阅获取Amazon授权代码时的错误。

    Alexa AppURL的HTTP GET请求的成功响应如果成功,响应是一个redirect URL,其中包括用户的Amazon授权代码和接下来显示的其他参数。要处理这个问题,请参阅苹果关于支持应用程序中通用链接的准则处理安卓应用程序链接的安卓指南

    // Example success response from the Alexa app
    https://yourRedirectURL?code={Amazon Authorization code}&state={your state}
    // Example success response from LWA
    https://yourRedirectURL?code={Amazon Authorization code}&scope={permission scope}&state={your state}

    地址

    • code:Alexa App或LWA返回的Amazon授权代码。

    • state:您在授权请求中传递的状态。您应该验证状态,以避免跨站点请求伪造。

    • scope:权限范围。LWA returnalexaalexa::skills:account_linking scope。Alexa App不返回范围参数。

    有关OAuth2.0授权响应的更多详细信息,请参阅授权响应Alexa AppURL的HTTP GET请求的错误响应如果出现错误,响应是redirect URL,接下来显示错误和其他参数。您的应用程序必须显示适当的错误信息(错误页面、UIAlertController等)。有关要显示的错误消息,请参阅获取Amazon授权代码时的错误码

    // Example error response
    HTTP 200
    https://yourRedirectURL?error_description={Error description}&state={your state}&error={OAuth Error Code}

    补充说明

    • error:单个ASCII错误代码。可能的值:invalid_request、unauthorized_client、access_denied、unsupported_response_type、invalid_scope、server_error、temporarily_unavailable。

    • error_description:错误描述。

    有关OAuth2.0错误响应的更多详细信息,请参阅授权响应

    示例:获取URL(iOS)

    在本例中,该应用程序同时获得Alexa App URL和LWA fallback URL。

    import Alamofire
    import SwiftyJSON
    class AlamofireHelper {
        // Your backend base URL from which you're requesting the URL
        static let BASE_URL = "https://exampleBackEndURL"
        /*! Get Alexa app Universal Link and LWA fallback URL
         @param userAccessToken for your backend server
         @param state maintained between client and server, caller should generate it 
         */
        static func getAlexaAppUrl(userAccessToken:String, state: String) -> DataRequest {
            let alexaAppUrl = "/alexaurl"
            let inputstate = "?state=" + state
            var urlRequest = URLRequest(url: URL(string: BASE_URL + alexaAppUrl + inputstate)!)
            urlRequest.addValue("Bearer " + userAccessToken, forHTTPHeaderField: "Authorization")
            return Alamofire.request(urlRequest)
        }
        private init() {}
    }
    // Call to your backend to get the authorization request URLs (Alexa app / LWA)
    AlamofireHelper.getAlexaAppUrl(userAccessToken: savedSession.accessToken, state: StateHelper.generateState()).responseJSON {
        response in
        guard let status = response.response?.statusCode, 200..<300 ~= status else {
            self.createAlert(title: "Failed to get Alexa URL", message: "Failed to get Alexa URL")
            return
        }
        guard let responseValue = response.result.value else {
            self.createAlert(title: "Invalid response", message: "Invalid response")
            return
        }
        let reponseInJSON = JSON(responseValue)
        guard let companionApp = reponseInJSON["companionAppURL"].string,let lwaFallback = reponseInJSON["LWAFallBackURL"].string else {
            self.createAlert(title: "Error response", message: "Error response")
            return
        }
        guard let companionAppURL = URL(string: companionApp), let lwaFallbackURL = URL(string: lwaFallback) else {
            self.createAlert(title: "Incorrect URL format", message: "Incorrect URL format")
            return
        }
        // The openUniversalLinks code snippet can be found in another example
        self.openUniversalLinks(companionAppURL: companionAppURL, lwaFallbackURL: lwaFallbackURL)
    }

    示例:打开URL(iOS)

    在本例中,该应用程序将打开Alexa App URL,如果没有安装Alexa App,则打开LWA fallback URL。

    注意以下内容:

    • 本示例使用UIApplication.shared.openAPI使用universalLinksOnly选项打开URL。

    • 只有当应用程序(此处为Alexa App)配置为使用universalLinksOnly选项处理通用链接时,URL才会打开。因此,您可以通过关闭表达式completionHandler处理Alexa App无法启动的情况,关闭表达式请参阅Closure Expressions(例如,Alexa App没有安装或版本不支持通用链接)。

    • 如果您的应用程序在使用universalLinksOnly选项时无法启动Alexa App,您的应用程序需要使用LWA获取Amazon授权代码。您可以在iOS 11上使用SFAuthenticationSession或iOS 12.0或更高版本上的ASWebAuthenticationSession。这将获得Safari用户会话和cookie,当用户已经在Safari上登录Amazon.com时,这避免了额外的登录。这将导致您的应用程序显示一个窗口,要求用户同意共享网站信息。该消息的内容由iOS定义。

    • 对于低于11.0的iOS版本,此示例使用SFSafariViewController将用户发送到LWA页面。SFSafariViewController不要求用户同意共享网站信息;因此,Safari浏览器不会与SFSafariViewController共享cookie。

    • 也可通过WKWebView打开LWA fallback URL,在WebView授权完成后会通过Universal Link打开您的App,拦截此URL获取授权参数。

      import SafariServices
      // Open the Alexa URL with option universalLinksOnly
      // If it doesn't open the Universal Link, open the LWA fallback 
      private func openUniversalLinks(companionAppURL: URL, lwaFallbackURL: URL) {
          UIApplication.shared.open(companionAppURL, options: [UIApplication.OpenExternalURLOptionsKey.universalLinksOnly:true]) {
              companionAppLaunched in
              if !companionAppLaunched {
                  if #available(iOS 12.0, *) {
                      WebSession.initWebAuthenticationSession(authURL: lwaFallbackURL)
                  } else if #available(iOS 11.0, *) {
                      WebSession.initAuthenticationSession(authURL: lwaFallbackURL)
                  } else {
                      let safariViewController =  SFSafariViewController(url: lwaFallbackURL)
                      self.present(safariViewController, animated: true, completion: nil)}
              }
          }
      }
      // Web session
      class WebSession {
          @available(iOS, introduced: 11.0, deprecated: 12.0)
          private static var authenticationSession: SFAuthenticationSession?
          @available(iOS 12.0, *)
          private static var webAuthenticationSession : ASWebAuthenticationSession?
          private static let CALLBACK_URL_SCHEME = "https://yourAppsUniversalLinkURL"
          @available(iOS, introduced: 11.0, deprecated: 12.0)
          static func initAuthenticationSession(authURL:URL) {
              if let session = authenticationSession {
                  session.cancel()
              }
              self.authenticationSession = SFAuthenticationSession.init(url: authURL, callbackURLScheme: CALLBACK_URL_SCHEME, completionHandler:{
                  (callBack:URL?, error:Error?) in
                  //Callback and error handling
              })
              self.authenticationSession?.start()
          }
          @available(iOS 12.0, *)
          static func initWebAuthenticationSession(authURL:URL) {
              if let session = webAuthenticationSession {
                  session.cancel()
              }
              self.webAuthenticationSession = ASWebAuthenticationSession.init(url: authURL, callbackURLScheme: CALLBACK_URL_SCHEME, completionHandler:{
                  (callBack:URL?, error:Error?) in
                  // Callback and error handling
              })
              self.webAuthenticationSession?.start()
          }
          private init(){}
      }

      示例:打开LWA URL(iOS)

      在网站的情况下打开LWA URL,作为iOS设备上没有安装Alexa App的fallback 。

      在本例中,该应用程序在视图控制器中打开LWA。

      import SafariServices
      // Open Login with Amazon in Safari View controller
      private func openSafariView(lwaFallbackURL: URL) {
          let safariViewController =  SFSafariViewController(url: lwaFallbackURL)
          self.present(safariViewController, animated: true, completion: nil)}
      }

      示例:处理通用链接响应(iOS)

      在本例中,该应用程序验证并提取Alexa App对其通用链接的调用中的参数。

      // Handler for Universal Links in AppDelegate
      // Add this method to handle incoming Universal Links
      func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
          guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
              let incomingURL = userActivity.webpageURL else {
                  return false
          }
          let handlers : [UniversalLinkHandler] = [AuthResponseHandler(), ErrorResponseHandler()]
          var validatedResponse : ValidatedResponse?
          for handler in handlers {
              if handler.canHandle(incomingURL: incomingURL) {
                  validatedResponse = handler.getValidatedResponse()
                  let initialViewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "linkingStatusScreen") as! LinkingStatusViewController
                  initialViewController.response = validatedResponse
                  self.window?.rootViewController = initialViewController
                  self.window?.makeKeyAndVisible()
                  return true
              }
          }
          return false
      }
      // Incoming Universal Links handler protocol
      protocol UniversalLinkHandler {
          //Validate the incoming URL scheme
          func canHandle(incomingURL:URL)-> Bool
          //Get the validated response
          func getValidatedResponse() -> ValidatedResponse?
      }
      extension UniversalLinkHandler {
          func validateState(state:String)->Bool {
              return StateHelper.validateState(state: state)
          }
      }
      // Successfully receiving the Amazon Authorization code
      class AuthResponseHandler : UniversalLinkHandler {
          private let AUTH_RESPONSE_PARAMETERS: Set = ["code", "state", "scope"]
          private var validatedResponse: ValidatedResponse?
          func canHandle(incomingURL: URL) -> Bool {
              guard let components = NSURLComponents(url: incomingURL, resolvingAgainstBaseURL: true),
                  let queryParameters = components.queryItems else {
                      return false
              }
              var validatedParameters : [String: String] = [:]
              for queryParameter in queryParameters {
                  //duplicated parameters in URL
                  if validatedParameters.keys.contains(queryParameter.name) {
                      return false
                  }
                  //Unrecognized parameters in URL
                  if !AUTH_RESPONSE_PARAMETERS.contains(queryParameter.name) {
                      return false
                  }
                  validatedParameters[queryParameter.name] = queryParameter.value
              }
              guard let code = validatedParameters["code"], let state = validatedParameters["state"] else {
                  return false
              }
              //validated the state
              if !validateState(state: state) {
                  self.validatedResponse = ErrorResponse(url: incomingURL,error: "Invalid state", errorDescription: "The request has invalid state")
                  return true
              }
              self.validatedResponse = AuthResponse(url: incomingURL,code: code, state: state, scope: validatedParameters["scope"])
              return true
          }
          func getValidatedResponse() -> ValidatedResponse? {
              return validatedResponse
          }
      }
      // Error authorization response handler
      class ErrorResponseHandler : UniversalLinkHandler {
          private let ERROR_RESPONSE_PARAMETERS: Set = ["error", "error_description", "state"]
          private var validatedResponse: ValidatedResponse?
          func canHandle(incomingURL: URL) -> Bool {
              guard let components = NSURLComponents(url: incomingURL, resolvingAgainstBaseURL: true),
                  let queryParameters = components.queryItems else {
                      return false
              }
              // Validate all query parameters 
              var validatedParameters : [String: String] = [:]
              for queryParameter in queryParameters {
                  // Duplicated parameters in URL
                  if validatedParameters.keys.contains(queryParameter.name) {
                      return false
                  }
                  // Unrecognized parameters in URL
                  if !ERROR_RESPONSE_PARAMETERS.contains(queryParameter.name) {
                      return false
                  }
                  validatedParameters[queryParameter.name] = queryParameter.value
              }
              guard let error = validatedParameters["error"], let errorDescription = validatedParameters["error_description"], let state = validatedParameters["state"] else {
                  return false
              }
              // validate the state
              if !validateState(state: state) {
                  self.validatedResponse = ErrorResponse(url: incomingURL,error: "Invalid state", errorDescription: "The request has invalid state")
                  return true
              }
              self.validatedResponse = ErrorResponse(url: incomingURL, error: error, errorDescription: errorDescription)
              return true
          }
          func getValidatedResponse() -> ValidatedResponse? {
              return validatedResponse
          }
      }
      // Validated response
      class ValidatedResponse {
          // Universal Link URL that launched the App
          private(set) var url: URL
          init (url: URL) {
              self.url = url
          }
      }
      // Amazon authorization code response per the OAuth2 specification 
      class AuthResponse : ValidatedResponse{
          private(set) var code : String
          private(set) var state: String
          //scope will be returned in Login with Amazon, will not be present in Alexa Companion flow
          private(set) var scope: String?
          init(url:URL, code:String, state:String, scope: String?) {
              self.code = code
              self.state = state
              self.scope = scope
              super.init(url:url)
          }
      }
      // Error response per the OAuth2 specification 
      class ErrorResponse : ValidatedResponse {
          private(set) var error : String
          private(set) var errorDescription: String
          init(url: URL, error:String, errorDescription:String) {
              self.error = error
              self.errorDescription = errorDescription
              super.init(url: url)
          }
      }

      状态生成和验证

      示例:State Helper

      在本例中,应用程序生成并验证请求和响应之间的状态。您必须使用状态验证传入的请求,以防止跨站点请求伪造,请参阅Cross-Site Request Forgery

      import Foundation
      import Security
      class StateHelper {
          private static let STATE_VALID_FOR = 3600
          // Base64 (iOS + TimeStamp + Secure Random UUID)
          private static let NUMBER_OF_PARAMETER = 3
          static func generateState(session: Session)-> String {
              var buffer = Data(count: 30)
              let _ = buffer.withUnsafeMutableBytes {
                  SecRandomCopyBytes(kSecRandomDefault, 30, $0)
              }
              let state = ("iOS." + String(Int64(Date().timeIntervalSince1970)) + "." + buffer.base64EncodedString()).encodeToURISafeBase64()
              // store state in session manager for future validation
              session.state = state
              LocalSessionManager.saveSession(session: session)
              return state
          }
          static func validateState(state: String) -> Bool {
              guard let originalState = LocalSessionManager.loadSession()?.state  else {
                  return false
              }
              if state != originalState {
                  return false
              }
              if let timeStampString = originalState.decodeFromURISafeBase64()?.split(separator: "."), timeStampString.count == NUMBER_OF_PARAMETER, let timeStamp = Int64(timeStampString[1]){
                  if Int64(Date().timeIntervalSince1970) - timeStamp <= STATE_VALID_FOR {
                      return true
                  }
              }
              if let session = LocalSessionManager.loadSession() {
                  session.state = nil
                  LocalSessionManager.saveSession(session: session)
              }
              return false
          }
          private init() {}
      }
      extension String {
          func decodeFromURISafeBase64() -> String? {
              let base64String = String(self.map {
                  // replace unsafe characters 
                  character in
                  if character == "." {
                      return  "+"
                  } else if character == "_" {
                      return "/"
                  } else if character == "-" {
                      return "="
                  }
                  return character
              })
              guard let data = Data(base64Encoded: base64String) else {
                  return nil
              }
              return String(data: data, encoding: .utf8)
          }
          func encodeToURISafeBase64() -> String {
              return String(Data(self.utf8).base64EncodedString().map{
                  // replace back unsafe characters 
                  character in
                  if character == "+" {
                      return  "."
                  } else if character == "/" {
                      return "_"
                  } else if character == "=" {
                      return "-"
                  }
                  return character
              })
          }
      }

  5. 将Amazon授权码交换为Amazon访问令牌,启用Skill并完成帐户链接。

    coderedirectUristate通过/living/voice/alexa/apptoapp/launch接口传给云端完成帐户链接。

  6. 在云智能iOS App中显示Account Linking状态。通过在云智能iOS App中显示帐户链接状态,例如在”用户体验和错误“中的屏幕截图中,让用户了解帐户链接状态。您可以使用不同的用户界面组件(例如表或页面视图)来显示帐户链接状态。

    示例:显示帐户链接状态。

    在本例中,该应用程序使用UIViewController显示帐户链接状态。

    1. 在Xcode IDE中,前往主故事板添加新的视图控制器。此视图控制器的故事板ID是linkingStatusScreen

    2. 添加一个新的Cocoa Touch类文件,选择UIViewController的子类,并在身份检查器中将您的类文件与UIViewController连接起来。

    3. 添加标题、显示帐户链接状态的UILabel状态、关闭此页面的UIButtonUIActivityIndicatorView,以便在应用程序调用Alexa服务时显示加载动画。

      import UIKit
      class LinkingStatusViewController: UIViewController {
          @IBOutlet weak var header: UILabel!
          @IBOutlet weak var status: UILabel!
          @IBOutlet weak var closeButton: UIButton!
          @IBOutlet weak var indicator: UIActivityIndicatorView!
          var response : ValidatedResponse?
          override func viewDidLoad() {
              super.viewDidLoad()
              if let res = self.response {
                  passValidatedResponse(validatedResponse: res)
              }
          }
          private func updateView(header:String, status:String, animating: Bool,closeButton:Bool) {
              if animating {
                  self.indicator?.startAnimating()
              } else {
                  self.indicator?.stopAnimating()
              }
              self.indicator?.isHidden = !animating
              self.header?.text = header
              self.status?.text = status
              self.closeButton.isEnabled = closeButton
              if closeButton {
                  self.closeButton.backgroundColor = #colorLiteral(red: 0.1960784314, green: 0.6039215686, blue: 0.8392156863, alpha: 1)
              } else {
                  self.closeButton.backgroundColor = #colorLiteral(red: 0.6000000238, green: 0.6000000238, blue: 0.6000000238, alpha: 1)
              }
          }
      }
      extension LinkingStatusViewController {
          func passValidatedResponse(validatedResponse: ValidatedResponse) {
              if validatedResponse is AuthResponse {
                  let response: AuthResponse = validatedResponse as! AuthResponse;
                  updateView(header: "Status", status: "Loading", animating: true, closeButton: false)
                  usingAuthCodeToGetAccessToken(amazonAuthCode: response.code)
              } else if (validatedResponse is ErrorResponse) {
                  let response: ErrorResponse = validatedResponse as! ErrorResponse;
                  updateView(header: response.error, status: response.errorDescription, animating: false, closeButton: true)
              } else {
                  updateView(header: "Unknown request", status: validatedResponse.url.absoluteString, animating: false, closeButton: true)
              }
          }
          // Complete account linking
          private func usingAuthCodeToGetAccessToken(amazonAuthCode: String) {
              guard let session = LocalSessionManager.loadSession(), session.sessionValid  else {
                  updateView(header: "Status", status: "User not login", animating: false, closeButton: true)
                  return;
              }
              AlamofireHelper.completeAccountLinking(userAccessToken: LocalSessionManager.loadSession()!.accessToken, amazonAuthCode: amazonAuthCode, state: StateHelper.generateState(session: session)).responseJSON(queue: DispatchQueue.global(qos: .userInitiated), options: []) {
                  response in
                  if let status = response.response?.statusCode, 200..<300 ~= status {
                      DispatchQueue.main.sync {
                          self.updateView(header: "Status", status: "Account Linking Succeed", animating: false, closeButton: true)
                      }
                      return
                  } else {
                      DispatchQueue.main.sync {
                          self.updateView(header: "Failed", status: "Account Linking failed", animating: false, closeButton: true)
                      }
                  }
              }
          }
      }
      // Open the account linking status page
      let initialViewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "linkingStatusScreen") as! LinkingStatusViewController
      initialViewController.response = validatedResponseself.window?.rootViewController = initialViewController
      self.window?.makeKeyAndVisible()
  7. 使用Skill中的访问令牌。

    用户成功启用您的Skill并将Alexa链接到您的服务后,发送到您的Skill的请求包括用户的访问令牌。您的Skill代码需要从请求中获取访问令牌,验证它,并使用它从资源服务器检索必要的用户信息。有关如何验证和使用访问令牌的详细信息,请参阅技能类型:

云端接口说明

/living/voice/alexa/apptoapp/config/get

获取Alexa App To App 的跳转参数

版本号:1.0.0

入参列表

无入参:

入参名称

数据类型

结构类型

是否必须

缺省默认值

入参示例

入参描述

子级参数类型

没有数据

出参列表

标准:

参数名称

数据类型

结构类型

后端参数名称

参数描述

子级参数类型

code

整型

code

响应码,200: 成功。

message

字符串

message

错误消息。

localizedMsg

字符串

localizedMsg

本地语言错误消息。

data

JSON

结构体

data

响应结果。

lwaFallbackUrl

字符串

lwaFallbackUrl

LWA Falback URL 当用户手机没有安装Alexa APP时,需要弹出webview 进入该URL进行LWA授权获取。

alexaAppUrl

字符串

alexaAppUrl

当用户手机已经安装了 Alexa APP 时打开该URL。

/living/account/oauth2/code/get

根据OAuth2规则生成当前登录账号的对外授权AuthCode

版本号:1.0.0

入参列表

入参名称

数据类型

结构类型

是否必须

缺省默认值

入参示例

入参描述

子级参数类型

redirectUri

字符串

Oauth2认证过程完成后回调请求授权第三方的回跳地址。

state

字符串

Oauth2认证过程中请求授权第三方携带的state。

clientId

字符串

Oauth2 认证过程中请求授权第三方服务商的client_id。

scope

JSON

数组

Oauth2授权过程中,请求授权的第三方服务商请求的授权权限范围。

字符串

出参列表

标准:

参数名称

数据类型

结构类型

后端参数名称

参数描述

子级参数类型

code

整型

code

响应码, 200: 成功。

message

字符串

message

错误消息。

localizedMsg

字符串

localizedMsg

本地语言错误消息。

data

字符串

data

生成的AuthCode。

/living/voice/alexa/apptoapp/launch

Alexa App-To-App流程获取到配置,跳转Alexa APP或者跳出LWA页面获取到Alexa颁发的AuthCode之后提交至云端完成剩下的绑定流程。

版本号:1.0.1

入参列表

入参名称

数据类型

结构类型

是否必须

缺省默认值

入参示例

入参描述

子级参数类型

code

字符串

Alexa LWA授权authCode。

state

字符串

Oauth2 认证参数state。

出参列表

标准:

参数名称

数据类型

结构类型

后端参数名称

参数描述

子级参数类型

code

整型

code

响应码,200: 成功。

message

字符串

message

错误消息。

localizedMsg

字符串

localizedMsg

本地语言错误消息。

附录:名词解释

相关术语

  • Service

    您提供的服务。例如,您可能有一个基于Web的服务"Ride Hailer",允许用户订购出租车。

  • App

    您的用户用于与您的服务进行交互的应用程序。继续上一个示例,您可能有一个"Ride Hailer"应用程序。

  • Skill

    Alexa的技能,使用户能够使用Alexa与您的服务交互。

  • Alexa app

    用户可以为其移动设备下载的Amazon Alexa App。下载方式请查看Alexa App

  • Login with Amazon (LWA)

    一种身份验证系统,允许用户登录并授予访问其用户配置文件数据的权限。就App to App Account Linking而言,LWA是一种后备方案,您可以实施它来处理用户在其移动设备上没有安装Alexa App的情况。有关LWA的一般信息,请参阅Login with Amazon(LWA)

  • OAuth 2.0

    一种身份验证标准,通过该标准,您的服务可以允许Alexa在用户许可的情况下访问用户与您建立的帐户中的信息。有关OAuth 2.0标准,请参见OAuth 2.0

  • App Link

    Android上的一个深度链接,用户点击该链接即可启动应用程序。有关App Link的详细信息,请参阅Android文档

  • Universal Link

    iOS上的一个深度链接,用户点击该链接即可启动应用程序。有关Universal Link的详细信息,请参阅iOS文档

名称

解释

示例

Alexa App URL

您的自有App使用此Universal Link(iOS)或App Link(Android)将用户跳转到Alexa App以确认链接请求。有关Universal Link(iOS)的一般信息,请参阅Universal Link Content

。 您可以在后端存储Alexa App URL和LWA fallback URL,并通过GET

请求检索它们。您还可以在本地存储URL,以避免额外的网络调用;如果将来修改技能安全配置文件,这可能需要重新构建。

https://alexa.amazon.com/spa/skill-account-linking-consent?fragment=skill-account-linking-consent&client_id={ClientId}&scope=alexa::skills:account_linking&skill_stage={skillStage}&response_type=code&redirect_uri={yourRedirectUrl}&state={yourState}

LWA fallback URL

将用户跳转到LWA以输入其Amazon账户信息的链接。它适用于iOS、Android和网站。如果用户的设备上没有安装Alexa App,则自有App需要使用此选项。

https://www.amazon.com/ap/oa?client_id={ClientId}&scope=alexa::skills:account_linking&response_type=code&redirect_uri={yourRedirectUrl}&state={yourState}

App redirect URLs

Alexa App(或LWA,如果未安装Alexa App)在用户确认Alexa App或LWA中的链接请求后,使用此Universal Link(iOS)或App Link(Android)将用户跳转回自有App。redirect

返回的参数为自有App提供了用户的Amazon授权码,有效期为5分钟。

您可以使用Alexa开发者控制台、ASK CLI或SMAPI指定其中一个或多个。请参阅URI规范

授权URL

授权服务器的URL。授权服务器必须接受用户的凭据,对用户进行身份验证,并生成一个授权代码,Alexa App稍后可以将该代码传递给授权服务器,以检索访问令牌,该令牌通过您的服务为唯一标识。

此链接仅用于常规(非应用到应用)帐户链接。您可以使用开发者控制台、ASK CLI或SMAPI来指定这一点。

Access token URL

Alexa使用您的令牌服务器将用户的授权码(用于您的服务)交换为访问令牌,以完成Account Linking。

您可以使用Alexa开发者控制台、ASK CLI或SMAPI来指定这一点。

LWA授权服务

您的后端服务器使用它将用户的Amazon授权代码交换为Amazon访问令牌。这使自有App能够调用Alexa服务来启用技能并为用户链接帐户。

Host: https://api.amazon.com,端口请求: /auth/o2/token

Alexa Skill API激活

您的后端服务器调用此函数以启用用户的技能。有关Alexa技能激活API的详细信息,请参阅Alexa Skill API激活

https://api.amazonalexa.com/v1/users/~current/skills/{yourSkillId}/enablement

取决于用户激活是否在本地,原始URL可以是https://api.amazonalexa.com,https://api.eu.amazonalexa.com或https://api.fe.amazonalexa.com

URL和相关参数说明

Alexa App URL和LWA fallback URL的参数说明

字段

描述

Client ID

在Alexa开发者控制台中启用App to App Account Linking时,Alexa开发者控制台提供的客户端ID。

Client secret

在Alexa开发者控制台中启用App to App Account Linking时,Alexa开发者控制台提供的客户端机密。

Redirect URL

在Alexa开发者控制台中启用App to App Account Linking时指定的Universal Link 或App Link。Alexa App和LWA在确认帐户链接请求后将用户重新跳回此URL。

Scope

范围,自有App必须将其设置为alexa::skills:account\u链接才能启用该技能。

Skill ID

您技能的唯一标识符。您可以在开发者控制台中找到它。

Stage

技能阶段,这取决于您的技能是否已发布。在你的技能发布之前,设置Stagedevelopment。发布技能后,请使用live

response type

响应类型必须为code,因为应用到应用帐户链接当前仅支持授权代码授予。

state

应用程序用于维护当前请求和响应之间状态的不透明值。对于Alexa App URL,状态值是必需的。Alexa使用redirect URL将用户转向回自有App时,在响应中包含此状态。您必须使用状态验证传入请求,以防止跨站点请求伪造,请参阅Cross-Site Request Forgery。 确保生成的状态不包含&、=、'、/、\、<、>、"、\"|字符。可以使用base64 URI作为一种安全的方法来执行此操作。有关示例实现,请参考下列开发步骤4:获取用户的Amazon授权代码中的状态帮助程序代码示例。 您可以使用安全随机数生成器生成状态,并将其保存到用户会话以进行验证。或者,您可以从后端服务器生成状态,并使用密钥对其进行加密和解密。