💳

iOSアプリでGitHubAPIのOAuth認証を行う

に公開

概要

  • GitHubAPIの認証方法にはいくつか種類があります
  • GitHub AppsがドキュメントでおすすめされているのでiOSアプリを作って試してみたのですが、権限不足でできないことがありました。
  • 例えばスターをつけようとすると権限が不足してエラーになります。
  • scopeの設定など色々試したのですがけっ結局うまくいかず(、恐らく組織/個人以外のリポジトリに対する用途ではないのかな、と解釈しています。

GitHub Apps と OAuth アプリの違い - GitHub Docs

  • そういう理由で今回はOAuthでの認証を行います

コード全体

コード全体
import SwiftUI

struct LoginView: View {
    
    let callbackURL = URL(string: "apollodemo://callback")!
    @State private var sessionCode: String?
    @State private var accessToken: String?
    
    var body: some View {
        Form {
            LabeledContent("Code", value: sessionCode ?? "(nil)")
            LabeledContent("AccessToken", value: accessToken ?? "(nil)")
                        
            Button("Log in") {
                let url = URL(string: "https://github.com/login/oauth/authorize?client_id=\(GitHubAPICredentials.clientID)&redirect_uri=\(callbackURL.absoluteString)")!
                UIApplication.shared.open(url)
            }
            .frame(maxWidth: .infinity, alignment: .center)
            
            Button("Fetch AccessToken") {
                Task {
                    do {
                        guard let sessionCode else {
                            return
                        }
                        self.accessToken = try await fetchAccessToken(sessionCode: sessionCode)
                    } catch {
                        print(error.localizedDescription)
                    }
                }
            }
            .disabled(sessionCode == nil)
            .frame(maxWidth: .infinity, alignment: .center)
        }
        .onOpenURL { url in
            guard
                let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
                let queryItems = components.queryItems,
                let sessionCode = queryItems.first(where: { $0.name == "code" })?.value
            else {
                fatalError("コールバックURLの値が不正です")
            }
            self.sessionCode = sessionCode
        }
    }
    
    private func fetchAccessToken(sessionCode: String) async throws -> String {
        // リクエストの作成
        let url = URL(string: "https://github.com/login/oauth/access_token")!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        // ヘッダの設定
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")
        request.addValue("application/json", forHTTPHeaderField: "Accept")
        // bodyの設定
        let bodyDict = [
            "client_id": GitHubAPICredentials.clientID,
            "client_secret": GitHubAPICredentials.clientSecret,
            "code": sessionCode
        ]
        let body = try JSONSerialization.data(withJSONObject: bodyDict, options: [])
        request.httpBody = body
        
        // リクエストの送信
        let (data, _) = try await URLSession.shared.data(for: request)

        // レスポンスのデコード
        let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
        guard
            let jsonDict = jsonObject as? [String: Any],
            let accessToken = jsonDict["access_token"] as? String
        else {
            fatalError("レスポンスのデコードに失敗")
        }
        
        return accessToken
    }
}

#Preview {
    LoginView()
}

OAuth Appの作成

  • 名前とコールバックURLを設定します
  • コールバックURLはこの後アプリ側でも同じ値をセットします
apollodemo://callback

  • また新しくClient secretを作成し、Client IDClient secretsをメモしておいて、後ほどアプリ側で利用します

  • OAuth Appの作成は以上です

コールバックURLの設定

  • 新規にiOSのプロジェクトを作成し、UTL Types > URL Schemeに先程のコールバックのSchemeを設定します
  • これでブラウザで認証を行った際、コールバックURLへのリダイレクトが起こり、アプリが立ち上がってコールバックURLを受け取ることができます

認証ページの作成

  • 先ほどの認証情報を含んだenumのファイルを作成します
    • e.g. GitHubAPICredentials.swift
  • セキュリティ的にこのファイルをGit管理するのは危ないので、.gitignoreに追加して無視させるといいです
enum GitHubAPICredentials {
    static let clientID = "YOUR_CLIENT_ID"
    static let clientSecret = "YOUR_CLIENT_SECRET"
}
struct LoginView: View {
    
    let callbackURL = URL(string: "apollodemo://callback")!
    
    var body: some View {
        Form {
            Button("Log in") {
                let url = URL(string: "https://github.com/login/oauth/authorize?client_id=\(GitHubAPICredentials.clientID)&redirect_uri=\(callbackURL.absoluteString)")!
                UIApplication.shared.open(url)
            }
            .frame(maxWidth: .infinity, alignment: .center)
        }
        .onOpenURL { url in
            print(url.absoluteString) // apollodemo://callback?code=8649aab669c51e066004
        }
    }
}
  • ブラウザで認証画面を開き、認証完了後にonOpenURLが呼ばれていれば成功です

コールバックURLからアクセストークンの取得

  • 続いてこのコールバックURLに含まれるクエリパラメータのcodeを使ってアクセストークンを取得します
apollodemo://callback?code=8649aab669c51e066004
  • コールバックURLのcodeをsessionCodeとして抽出します
guard
    let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
    let queryItems = components.queryItems,
    let sessionCode = queryItems.first(where: { $0.name == "code" })?.value
else {
    fatalError("コールバックURLの値が不正です")
}
self.sessionCode = sessionCode
  • このsessionCodeを使ってアクセストークンが取得できます
  • 下記の通りリクエストを作成して、成功するとレスポンスからアクセストークンを取得できます
  1. GitHub によってユーザーが元のサイトにリダイレクトされる
    https://docs.github.com/ja/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#2-users-are-redirected-back-to-your-site-by-github
func fetchAccessToken(sessionCode: String) async throws -> String {
    // リクエストの作成
    let url = URL(string: "https://github.com/login/oauth/access_token")!
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    // ヘッダの設定
    request.addValue("application/json", forHTTPHeaderField: "Content-Type")
    request.addValue("application/json", forHTTPHeaderField: "Accept")
    // bodyの設定
    let bodyDict = [
        "client_id": GitHubAPICredentials.clientID,
        "client_secret": GitHubAPICredentials.clientSecret,
        "code": sessionCode
    ]
    let body = try JSONSerialization.data(withJSONObject: bodyDict, options: [])
    request.httpBody = body
    
    // リクエストの送信
    let (data, _) = try await URLSession.shared.data(for: request)

    // レスポンスのデコード
    let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
    guard
        let jsonDict = jsonObject as? [String: Any],
        let accessToken = jsonDict["access_token"] as? String
    else {
        fatalError("レスポンスのデコードに失敗")
    }
    
    return accessToken
}
  • 今回リクエストのヘッダでAcceptでjson形式を指定しているので、下記の形でレスポンスのデータが返ってきています
  • これをDictionaryとしてデコードして、アクセストークン値を取り出すことができます
{
  "access_token":"gho_16C7e42F292c6912E7710c838347Ae178B4a",
  "scope":"repo,gist",
  "token_type":"bearer"
}
  • ちなみにaccessTokenの期限は無期限とのことです

【保存版】GitHubアクセストークン全種別解説: Personal Access Token、OAuth、GitHub App、GITHUB_TOKENの違い #GitHubAPI - Qiita

Discussion