松崎 剛 Blog

This Blog's theme : エンタープライズ開発 (Server side)、Office サーバ開発

SharePoint 2013 Apps: .NET CSOM を使ったプログラミングと認証 (Authentication)

SharePoint 2013 Apps: .NET CSOM を使ったプログラミングと認証 (Authentication)

  • Comments 0

環境 :
Office 365 (Preview)
Visual Studio 2012
Microsoft Office Developer Tools for Visual Studio 2012 Preview

SharePoint 2013 Apps 開発

こんにちは。

BUILD に続き、いよいよ、SharePoint Conference が目前に迫りました。ということで、そろそろ、次期開発基盤である Apps for SharePoint 2013 の手法を紹介していたいと思います。(今まで MSDN に掲載していたのですが、今回、本ブログを使って掲載したいと思います。)

SharePoint Apps を使って SharePoint と連携する処理 (例 : アイテムの取得、更新など) をプログラミングする際は、「Apps for SharePoint の動作と概要」で解説したように、サーバー サイドで連携する方法と、クライアント サイド (JavaScript) で連携する方法があります。今回は、サーバー サイドの連携方法について、実際のサンプル コードを含めて解説します。(といっても、後述するように、今回は、プロジェクトが生成するコードの解説がほとんどです。)

なお、新しい SharePoint Apps の開発の全体については、前回記載した「Apps for SharePoint の動作と概要」を参照してください。

また、現在の Preview 版では日本語で一部が使えない機能があるため、以降、英語版を使用しますが、ご容赦ください。(Permission 設定などを使わないなら、概ね、日本語環境でも問題ありません。)
2012/11/19 追記 : このバグは、Microsoft Office Developer Tools for Visual Studio 2012 Preview 2 で修正されました。

 

プロジェクトの作成

今回は、Remote App のアプリケーションを作成し、Remote App のサーバー側のプログラム (C#) から SharePoint サイトのタイトルを取得して表示する簡単な Page アプリケーション (ASP.NET アプリケーション) を構築します。

まず、App for SharePoint 2013 のプロジェクトを新規作成します。

ウィザードが表示されるので、今回は、配置方法として、Autohosted を選択します。(Autohosted の動作については、「Apps for SharePoint の動作と概要」を参照してください。)

補足 : 「Apps for SharePoint の動作と概要」で解説したように、Autohosted では、上図のデバッグ用のサイト (SharePoint サイト) として、オンプレミスの SharePoint Server ではなく、Office 365 のサイト (Sharepoint Online のサイト) を使用してください。

補足 : Provider-hosted の App を Debug 用に外に配置 (展開) して試したい場合は、あらかじめ、/_layouts/15/AppRegNew.aspx を使用してアプリケーションを登録してください。詳細の手順は、「On-Premises と Office 365 で動く App を作成するには (Authentication)」を参照してください。

以上で、テンプレートからプロジェクトが自動作成されます。
実は、このプロジェクトのテンプレートで、既に、必要な認証の処理と、.NET CSOM (Client Side Object Model) を使ったプログラム コードが作成されています。(デバッグ実行をおこなうと、ちゃんと動作します。)
以降では、この内容 (流れ) について解説します。

まず、準備として、App プロジェクトのマニフェスト ファイル (AppManifest.xml) のコードを開いてください (下記)。
ユーザーが SharePoint App をインストールすると Site Contents としてアプリケーションが登録されますが、この登録されたアプリケーションをクリックした際に飛ぶ URL が、下記の <StartPage /> 要素 (太字) になります。
この <StartPage /> 要素に記述されている ~remoteAppUrl、{StandardTokens} はトークンと呼ばれる約束語で、実行時には、~remoteAppUrl は実際の配置先のアプリケーションの URI が設定され、{StandardTokens} には必要な情報が Query String として渡されます。
この {StandardTokens} については、このあと解説しますので、おぼえておいてください。

<?xml version="1.0" encoding="utf-8" ?>
<App xmlns="..." . . .>
  <Properties>
    <Title>SharePointApp1</Title>
    <StartPage>~remoteAppUrl/Pages/Default.aspx?{StandardTokens}</StartPage>
  </Properties>
  <AppPrincipal>
    <AutoDeployedWebApplication/>
  </AppPrincipal>
  . . .

 

OAuth 2 による server to server 認証と SharePoint へのアクセス

Remote App (サーバー側) から SharePoint (今回の場合、SharePoint Online) にアクセスして必要な情報の取得や更新をおこなう場合、まずは認証処理をおこなって、SharePoint にログインする必要があります。この認証処理では、OAuth 2 による server to server 認証を使用します。
後述するように、この認証処理は API がおこなってくれるので、プログラマーが細かくプログラミングする必要はありません。しかし、流れを理解しておくことは重要なので、以下に整理しておきます。

Office 365 Preview では、以前このブログでも解説した Windows Azure Active Directory (AAD) が使用されています。まず、App がインストールされると、Windows Azure Active Directory に専用の Service Principal が作成されます。

補足 : SharePoint の場合、/_layouts/15/AppRegNew.aspx から個別に Service Principal を登録することも可能です。Provider-hosted の App を開発サイトに手動でインストールする場合など、この方法が使えます。(登録された内容は、/_layouts/15/AppInv.aspx で確認できます。)

補足 : 作成された Service Principal がゴミとして残ってしまう場合があるので、定期的に消しておくと良いでしょう。確認方法と削除方法については、「SharePoint 2013 Apps で token が null になる場合の対処」に掲載しました。(2013/02/27追記)

インストールされた Remote App が呼ばれる際に、SharePoint は、その SharePoint サイトのログインに必要な情報 (トークン) を HTTP POST を使って渡します。
このトークンは、AppContext、AppContextToken、AccessToken、SPAppToken のいずれかのパラメーターに、JWT (Json 形式のトークン) として格納されています。厳密には、このパラメーターに . (dot) で区切られた配列として文字列が渡され、その配列の要素の 1 つとして、エンコードされた JWT が設定されています。(このデコード プログラムを「OAuth 2 Token の Decode」に掲載しました。)
つまり、Remote App 側では、まず、この渡されたパラメーターを Parse して、JWT を取り出します。

渡される JWT は、以下のフォーマットの Json 文字列です。
なお、下記で identifier (識別子) は、以前 紹介した Windows Azure Active Directory で使用した <AppPrincipalID>@<TenantId> 形式の識別子です。

{
  "aud":"<audience identifier (即ち、今回の場合、Remote App の identifier)>",
  "iss":"<issuer identifier>",
  "nbf":<starting time>,
  "exp":<expiration time>,
  "appctxsender":"<appctx の sender の identifier>",
  "appctx":"<後述>",
  "refreshtoken":"<OAuth refresh token>",
  "isbrowserhostedapp":"true"
}

上記の appctx (アプリケーション コンテキスト) には、下記のフォーマットの JSON がエスケープされた文字列として設定されています。

{
  "CacheKey":"<Cache key>",
  "SecurityTokenServiceUri":"https://accounts.accesscontrol.windows.net/tokens/OAuth/2"
}

実際の JWT のサンプルは、以下のような感じです。(本来、こうした内容は他人に見せるものではありませんが、この後のプログラミングにも影響するので、あえて人柱となって記載します。)
なお、Refresh token は、さすがに長いので省略しています。

{
  "aud":"8c3672a6-15c2-489b-813b-a6424cf6d98e/localhost:44301@e4b292c8-102a-4035-a1b1-1666c90b94cb",
  "iss":"00000001-0000-0000-c000-000000000000@e4b292c8-102a-4035-a1b1-1666c90b94cb",
  "nbf":1352096980,
  "exp":1352140180,
  "appctxsender":"00000003-0000-0ff1-ce00-000000000000@e4b292c8-102a-4035-a1b1-1666c90b94cb",
  "appctx":"{
    \"CacheKey\":\"pDp3sFAezpQ/frI+9mLCB3aL6mfSmdzWUFZ9wN0S8qo=\",
    \"SecurityTokenServiceUri\":\"https://accounts.accesscontrol.windows.net/tokens/OAuth/2\"
  }",
  "refreshtoken":"IAAAAD1DB3 ...",
  "isbrowserhostedapp":"true"
}

つぎに、上記の OAuth refresh token を使って、STS に対し、OAuth の access token を要求します。
そして、受け取った access token を使用し、下記の Http header を付与することで、SharePoint の REST サービスや CSOM による呼び出しが可能になります。(SharePoint 2013 では、CSOM も最終的に、REST を呼び出しています。)

Authorization : Bearer <access token>

なお、上述した {StandardTokens} (上記の AppManifest.xml 参照) には、下記のフォーマットのクエリー文字列が設定されます。

SPHostUrl=<使用している Host web (SharePoint) の URL>

つまり、このクエリー文字列から、現在使用している SharePoint の URL (Host web の Url) が取得できるため、以降は、この URL に対して REST (または、CSOM) でアクセスします。

App for SharePoint 2013 のテンプレートが作成するプロジェクトでは、上記の手続きが、最新の Windows Identity Foundation (WIF) でプログラミングされています。(正確には、WIF 1.1 のライブラリーである Microsoft.IdentityModel.Extensions.dll と、Microsoft.IdentityModel.dll の runtime version 2 を使用しています。)
例えば、これらのライブラリーを使って OAuth の Access Token を取得するには、下記の通りプログラミングできます。(実際にプロジェクトが生成するコードでは、Validation チェックや例外処理などが含まれており、さらに複雑なコードです。)

. . .
using System.Web.Configuration;
using System.IdentityModel.Tokens;
using System.IdentityModel.Selectors;
using Microsoft.IdentityModel.Tokens;
using Microsoft.IdentityModel.S2S.Tokens;
using Microsoft.IdentityModel.S2S.Protocols.OAuth2;
. . .

protected void Page_Load(object sender, EventArgs e)
{
  // 実際の開発では、Audience の Validation チェックなどをおこなうこと
  // (今回は省略してます)
  string hostWeb = Page.Request["SPHostUrl"];
  string contextToken = Page.Request.Form["SPAppToken"];

  string clientId = WebConfigurationManager.AppSettings.Get("ClientId");
  string clientSecret = WebConfigurationManager.AppSettings.Get("ClientSecret");

  // ready JsonWebSecurityTokenHandler
  JsonWebSecurityTokenHandler tokenHandler =
    new JsonWebSecurityTokenHandler();
  tokenHandler.Configuration = new SecurityTokenHandlerConfiguration();
  tokenHandler.Configuration.AudienceRestriction =
    new AudienceRestriction(AudienceUriMode.Never);
  tokenHandler.Configuration.CertificateValidator =
    X509CertificateValidator.None;
  byte[] clientSecretByte = Convert.FromBase64String(clientSecret);
  tokenHandler.Configuration.IssuerTokenResolver =
    SecurityTokenResolver.CreateDefaultSecurityTokenResolver(
    new ReadOnlyCollection<SecurityToken>(new List<SecurityToken>(
      new SecurityToken[]
      {
        new SimpleSymmetricKeySecurityToken(clientSecretByte)
      })),
    false);

  // Create JsonWebSecurityToken from JWT string
  JsonWebSecurityToken jsonToken =
    tokenHandler.ReadToken(contextToken)
    as JsonWebSecurityToken;

  // Get principalName from JWT
  var appctxsender = (from c in jsonToken.Claims
    where c.ClaimType == "appctxsender"
    select c.Value).FirstOrDefault();
  string principalName = appctxsender.Split('@')[0];

  // Get OAuth refresh token from JWT
  var refreshToken = (from c in jsonToken.Claims
    where c.ClaimType == "refreshtoken"
    select c.Value).FirstOrDefault();

  // Get app realm from JWT
  string aud = jsonToken.Audience;
  string realm = aud.Substring(aud.IndexOf('@') + 1);

  // Get host web authority
  string hostAuthority = (new Uri(hostWeb)).Authority;

  // Create OAuth resource id
  string resource = String.Format(
    CultureInfo.InvariantCulture,
    "{0}/{1}@{2}",
    principalName, hostAuthority, realm);

  // Create OAuth client id
  string oauthClientId = String.Format(
    CultureInfo.InvariantCulture,
    "{0}@{1}",
    clientId, realm);

  // Get sts endpoint for OAuth S2S
  JsonMetadataDocument jdoc = GetJsonMetadataFromAcs(realm);
  JsonEndpoint s2s = (from c in jdoc.endpoints
    where c.protocol == "OAuth2"
    select c).FirstOrDefault();
  string stsOAuth2Url = s2s.location;

  // Get access token from refresh token
  OAuth2S2SClient client = new OAuth2S2SClient();
  OAuth2AccessTokenRequest oauthReq =
    OAuth2MessageFactory.CreateAccessTokenRequestWithRefreshToken(
      oauthClientId,
      clientSecret,
      refreshToken,
      resource);
  OAuth2AccessTokenResponse oauthRes =
    client.Issue(
      stsOAuth2Url,
      oauthReq) as OAuth2AccessTokenResponse;
  string accessToken = oauthRes.AccessToken;

  Response.Write("Access Token is " + accessToken);
}

private JsonMetadataDocument GetJsonMetadataFromAcs(string realm)
{
  string endpoint = String.Format(
    CultureInfo.InvariantCulture,
    "{0}?realm={1}",
    "https://accounts.accesscontrol.windows.net/metadata/json/1",
    realm);
  byte[] metadata;
  using (WebClient webClient = new WebClient())
  {
    metadata = webClient.DownloadData(endpoint);
  }
  string jsonString = Encoding.UTF8.GetString(metadata);
  JavaScriptSerializer serializer = new JavaScriptSerializer();
  JsonMetadataDocument document =
    serializer.Deserialize<JsonMetadataDocument>(jsonString);
  return document;
}

. . .

// These classes are used for Json serialization.
// (Mapped class)
public class JsonMetadataDocument
{
  public string serviceName { get; set; }
  public List<JsonEndpoint> endpoints { get; set; }
  public List<JsonKey> keys { get; set; }
}

public class JsonEndpoint
{
  public string location { get; set; }
  public string protocol { get; set; }
  public string usage { get; set; }
}

public class JsonKeyValue
{
  public string type { get; set; }
  public string value { get; set; }
}

public class JsonKey
{
  public string usage { get; set; }
  public JsonKeyValue keyValue { get; set; }
}
. . .

このコードでは、毎回、トークンを取り直していますが、取得したトークンを Cookie や AppFabric Cache などに保持して再利用しても構いません。その場合には、前述の Cache Key が使用できます。

補足 : 取得した Access token は 12 時間で無効になります。長時間の処理などでトークンが無効になった場合は、「MSDN : Tips and FAQs OAuth and remote apps for SharePoint 2013」に記載されているように、 /_layouts/15/AppRedirect.aspx を使ってトークンを取り直すことができます。(AppRedirect.aspx は、新しい JWT を POST して、remote app にリダイレクトします。)

なお、上記のコードを見ておわかりの通り、オンプレミスの SharePoint に認証する場合、上記のコードは、そのまま使用できないので注意してください。(面倒ですが、コードは、認証方法によって異なってきます。上記のコードは Office 365 の認証基盤、すなわち、Windows Azure の認証基盤へ接続しています。)

上記で取得した accessToken を使って REST で Web の Title を取得するには、下記の通りプログラミングします。(あらかじめ、System.Net.Http.dll、System.Net.Http.Formatting.dll の参照を追加し、NuGet から Json.NET を取得します。)
なお、下記はサンプルですので、実際のプログラミングでは、非同期をうまく活用してください。(つまり、真似しないでください。)

. . .
using System.Threading.Tasks;
using System.Net.Http;
using System.Net.Http.Formatting;
using Newtonsoft.Json.Linq;

. . .

// Get web title
HttpClient restCl = new HttpClient();
restCl.DefaultRequestHeaders.Add(
  "Accept",
  "application/json; odata=verbose");
restCl.DefaultRequestHeaders.Add(
  "Authorization",
  " Bearer " + accessToken);
HttpResponseMessage restRes =
  restCl.GetAsync(hostWeb + "/_api/web/Title").Result;
JObject jreturn =
  restRes.Content.ReadAsAsync<JObject>(
    new[] { new JsonMediaTypeFormatter() }).Result;
JObject jd = (JObject)jreturn["d"];
string title = ((JValue)jd["Title"]).Value.ToString();
Response.Write("Web title is " + title);
. . .

補足 : 上記は、SharePoint 2013 から使用可能な新しい REST サービスです。(CSOM は、内部で、この REST サービスを使用します。実体は、client.svc です。) 従来の REST サービスも、そのまま使用できます。

同様に、accessToken を使って .NET CSOM (Client Side Object Model) で Web の Title を取得するには、下記の通りプログラミングします。

. . .
using Microsoft.SharePoint.Client;

. . .

// Get web title
ClientContext clCtx = new ClientContext(hostWeb);
clCtx.AuthenticationMode = ClientAuthenticationMode.Anonymous;
clCtx.FormDigestHandlingEnabled = false;
clCtx.ExecutingWebRequest +=
    delegate(object oSender, WebRequestEventArgs webRequestEventArgs)
    {
        webRequestEventArgs.WebRequestExecutor.RequestHeaders["Authorization"]
            = "Bearer " + accessToken;
    };
clCtx.Load(clCtx.Web, web => web.Title);
clCtx.ExecuteQuery();
Response.Write("Web title is " + clCtx.Web.Title);
. . .

なお、Apps for SharePoint 2013 のプロジェクトに含まれている TokenHelper クラス のメソッドには、こうした認証処理を実装した多数のメソッドが提供されていて、これらのメソッドを組み合わせることで簡単に認証処理が実装できるようになっています。例えば、上記の処理は、以下の通り記述できます。(これが、Apps for SharePoint 2013 のプロジェクト作成時に生成されるコードです。)

protected void Page_Load(object sender, EventArgs e)
{
  var contextToken =
    TokenHelper.GetContextTokenFromRequest(Page.Request);
  var hostWeb = Page.Request["SPHostUrl"];
  using (var clientContext =
    TokenHelper.GetClientContextWithContextToken(
    hostWeb, contextToken, Request.Url.Authority))
  {
    clientContext.Load(
      clientContext.Web,
      web => web.Title);
    clientContext.ExecuteQuery();
    Response.Write(
      "Web title is " + clientContext.Web.Title);
  }
}

 

Permission と App Context

Apps for SharePoint の動作と概要」でも解説しましたが、さいごに、Permission の設定をおこなってください。
例えば、今回は、SharePoint サイトのタイトルを読み込んで表示するだけなので、AppManifest.xml をダブルクリックして、表示されるウィンドウで、下記の通り、「Web」スコープの「Read」権限を要求します。

設定をおこなうと、AppManifest.xml には下記 (太字) の通り設定されます。(もちろん、要領がつかめてきたら、AppManifest.xml のテキストを直接編集して構いません。)

<App xmlns="..." . . .>
  . . .

  <AppPermissionRequests>
    <AppPermissionRequest
      Scope="http://sharepoint/content/sitecollection/web"
      Right="Read" />
  </AppPermissionRequests>

</App>

Apps for SharePoint の動作と概要」でも解説した通り、Permission 設定をおこなうと、App のインストール時に、この App が要求している Permission を付与するかどうかを確認する画面が表示されます。この画面で管理者が権限を付与 (Trust) すると、SharePoint には、App と Permission の情報が登録されます。そして、App が上記の Refresh token を使用した方法で SharePoint にアクセスする場合、OAuth のトークンには App Context と呼ばれるアプリケーションの情報が含まれていて、この Context の情報を元に、アクセスしている App が、その操作の権限があるかチェックします。(権限のない App に対しては、Permission エラーが表示されます。)
また、上記の Refresh token を使用した方法では、OAuth のトークンに User Context も含まれており、どのユーザーが操作した内容であるかも SharePoint 側で認識されています。例えば、アイテムを作成すると、作成者 (CreatedBy) としてユーザーの情報も設定され、そのアイテムの振る舞い (権限の動作など) も、そのユーザーが画面から登録したアイテムと同じように動作します。
User Context を含まず、App Context のみの Access Token を取得したい場合は、プロジェクトの TokenHelper.GetAppOnlyAccessToken メソッドが使用できます。(どのように処理されているか参照してみてください。)

注意 : App のみの AccessToken を使用 (TokenHelper.GetAppOnlyAccessToken を使用) する際は、AppManifest.xml に、下記の通り AllowAppOnlyPolicy 属性を設定します。
<AppPermissionRequests AllowAppOnlyPolicy="true">
  . . .
</AppPermissionRequests>

なお、SharePoint Store App の場合は、AppManifest.xml に、Locale 情報も忘れずに設定しておきましょう。Locale に関する詳細は、チームブログの「Locale support information is required for all apps in the SharePoint Store」を参照してください。(今後、この Locale の説明は省略します。)

 

動作確認

以上で完了です。

Visual Studio で F5 キー (または、[Debug] - [Start Debugging] メニュー) でデバッグ実行をおこなうと、デバッグ用に使用している SharePoint サイトに、このアプリケーションが配置 (登録) されます。(この際、SharePoint へのログインが必要となります。また、「Apps for SharePoint の動作と概要」でも解説した Permission の要求画面が表示されます。)
SharePoint サイトにインストールされると、下図の通り、アプリケーションは、[Site Contents] に表示されます。ここは、リスト、ライブラリー、外部からインストールしたアプリケーションなど、サイトでインストールされたすべてのコンテンツが表示されています。

上図で、インストールされた「SharePointApp1」をクリックすると、下図の通り、Remote App (Default.aspx) が表示されます。

補足 : chrome control を使用すると、SharePoint と同じブランディング (UI レイアウト) のページを表示できます。(元の Host web に戻るためのリンクなども表示されます。)

補足 : デバッグ実行時は、ローカル (localhost) の IIS Express の SSL (https) が使用されます。このため、この SSL で使用されている開発用の証明書をブラウザー (Internet Explorer) の信頼されたルート (Trusted Root Certification Authorities) に登録してください。(登録しないと、証明書のエラーが表示され、トークンが正しく POST されません。)

補足 : Provider-hosted の展開方法については、上述の通りです。

補足 (2013/07 追記) : SharePoint の外の Native Application などから Access Token を取得して SharePoint に接続することも可能です。詳細は、「Native Application で SharePoint Online に Login して REST サービスなどを呼び出すプログラミング」に記載しました。

URL を見ていただくわかりますが、デバッグ実行をした際は、既定で、ローカルの IIS Express が使用されます。Autohosted のアプリケーションで、実際に、Windows Azure に host して動作確認する場合には、Visual Studio から [Publish] (発行) をおこなって .app ファイル (パッケージ) を作成し、[Apps in Testing] ライブラリーに登録してください。

次回は、Apps 版の Web Parts である App part の開発手法について解説します。

 

Windows Azure のトライアル (無償試用) について

Leave a Comment
  • Please add 7 and 8 and type the answer here:
  • Post