Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add authentication before checking for updates #6

Merged
merged 9 commits into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 36 additions & 20 deletions Example/DemoApp/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,44 @@
import SwiftUI

struct ContentView: View {
var body: some View {
VStack(spacing: 20.0) {
Button("Check For Update Swift") {
UpdateUtil.checkForUpdates()
}
.padding()
.background(.blue)
.foregroundColor(.white)
.cornerRadius(10)

Button("Check For Update ObjC") {
UpdateUtilObjc().checkForUpdates()
}
.padding()
.background(.gray)
.foregroundColor(.white)
.cornerRadius(10)
}
.padding()
var body: some View {
VStack(spacing: 20.0) {
Button("Check For Update Swift") {
UpdateUtil.checkForUpdates()
}
.padding()
.background(.blue)
.foregroundColor(.white)
.cornerRadius(10)

Button("Check For Update With Login Swift") {
UpdateUtil.checkForUpdatesWithLogin()
}
.padding()
.background(.orange)
.foregroundColor(.white)
.cornerRadius(10)

Button("Check For Update ObjC") {
UpdateUtilObjc().checkForUpdates()
}
.padding()
.background(.gray)
.foregroundColor(.white)
.cornerRadius(10)

Button("Check For Update With Login ObjC") {
UpdateUtilObjc().checkForUpdatesWithLogin()
}
.padding()
.background(.yellow)
.foregroundColor(.black)
.cornerRadius(10)
}
.padding()
}
}

#Preview {
ContentView()
ContentView()
}
54 changes: 40 additions & 14 deletions Example/DemoApp/UpdateUtil.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,48 @@ import ETDistribution
struct UpdateUtil {
static func checkForUpdates() {
ETDistribution.shared.checkForUpdate(params: CheckForUpdateParams(apiKey: Constants.apiKey)) { result in
switch result {
case .success(let releaseInfo):
if let releaseInfo {
print("Update found: \(releaseInfo)")
guard let url = ETDistribution.shared.buildUrlForInstall(releaseInfo.downloadUrl) else {
return
}
UIApplication.shared.open(url) { _ in
exit(0)
}
} else {
print("Already up to date")
switch result {
case .success(let releaseInfo):
if let releaseInfo {
print("Update found: \(releaseInfo)")
guard let url = ETDistribution.shared.buildUrlForInstall(releaseInfo.downloadUrl) else {
return
}
DispatchQueue.main.async {
UIApplication.shared.open(url) { _ in
exit(0)
}
case .failure(let error):
print("Error checking for update: \(error)")
}
} else {
print("Already up to date")
}
case .failure(let error):
print("Error checking for update: \(error)")
}
}
}

static func checkForUpdatesWithLogin() {
let params = CheckForUpdateParams(apiKey: Constants.apiKey, requiresLogin: true)
ETDistribution.shared.checkForUpdate(params: params) { result in
switch result {
case .success(let releaseInfo):
if let releaseInfo {
print("Update found: \(releaseInfo)")
guard let url = ETDistribution.shared.buildUrlForInstall(releaseInfo.downloadUrl) else {
return
}
DispatchQueue.main.async {
UIApplication.shared.open(url) { _ in
exit(0)
}
}
} else {
print("Already up to date")
}
case .failure(let error):
print("Error checking for update: \(error)")
}
}
}
}
3 changes: 2 additions & 1 deletion Example/DemoApp/UpdateUtilObjc.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ NS_ASSUME_NONNULL_BEGIN

@interface UpdateUtilObjc : NSObject
- (void) checkForUpdates;
- (void) checkForUpdatesWithLogin;
@end

NS_ASSUME_NONNULL_END
NS_ASSUME_NONNULL_END
11 changes: 11 additions & 0 deletions Example/DemoApp/UpdateUtilObjc.m
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,15 @@ - (void) checkForUpdates {
NSLog(@"Error checking for update: %@", error);
}];
}

- (void) checkForUpdatesWithLogin {
CheckForUpdateParams *params = [[CheckForUpdateParams alloc] initWithApiKey:[Constants apiKey] tagName:[Constants tagName] requiresLogin:YES];
[[ETDistribution sharedInstance] checkForUpdateWithParams:params
onReleaseAvailable:^(DistributionReleaseInfo *releaseInfo) {
NSLog(@"Release info: %@", releaseInfo);
}
onError:^(NSError *error) {
NSLog(@"Error checking for update: %@", error);
}];
}
@end
220 changes: 220 additions & 0 deletions Sources/Auth.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
//
// Auth.swift
// ETDistribution
//
// Created by Noah Martin on 9/23/24.
//

import AuthenticationServices
import CommonCrypto

enum LoginError: Error {
case noUrl
case noCode
case invalidData
}

enum Auth {
private enum Constants {
static let url = URL(string: "https://auth.emergetools.com")!
static let clientId = "XiFbzCzBHV5euyxbcxNHbqOHlKcTwzBX"
static let redirectUri = URL(string: "app.install.callback://callback")!
static let accessTokenKey = "accessToken"
static let refreshTokenKey = "refreshToken"
}

static func getAccessToken(settings: CheckForUpdateParams.LoginSetting, completion: @escaping (Result<String, Error>) -> Void) {
if let token = KeychainHelper.getToken(key: Constants.accessTokenKey),
JWTHelper.isValid(token: token) {
completion(.success(token))
} else if let refreshToken = KeychainHelper.getToken(key: Constants.refreshTokenKey) {
refreshAccessToken(refreshToken) { result in
switch result {
case .success(let accessToken):
completion(.success(accessToken))
case .failure(let error):
requestLogin(settings, completion)
}
}
} else {
requestLogin(settings, completion)
}
}

private static func requestLogin(_ settings: CheckForUpdateParams.LoginSetting, _ completion: @escaping (Result<String, Error>) -> Void) {
login(settings: settings) { result in
switch result {
case .success(let response):
do {
try KeychainHelper.setToken(response.accessToken, key: Constants.accessTokenKey)
try KeychainHelper.setToken(response.refreshToken, key: Constants.refreshTokenKey)
completion(.success(response.accessToken))
} catch {
completion(.failure(error))
}
case .failure(let error):
completion(.failure(error))
}
}
}

private static func refreshAccessToken(_ refreshToken: String, completion: @escaping (Result<String, Error>) -> Void) {
let url = URL(string: "oauth/token", relativeTo: Constants.url)!

let parameters = [
"grant_type": "refresh_token",
"client_id": Constants.clientId,
"refresh_token": refreshToken,
]

var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try! JSONSerialization.data(withJSONObject: parameters, options: [])

URLSession(configuration: URLSessionConfiguration.ephemeral).refreshAccessToken(request) { result in
switch result {
case .success(let response):
do {
try KeychainHelper.setToken(response.accessToken, key: Constants.accessTokenKey)
completion(.success(response.accessToken))
} catch {
completion(.failure(error))
}
case .failure(let error):
completion(.failure(error))
}
}
}

private static func login(
settings: CheckForUpdateParams.LoginSetting,
completion: @escaping (Result<AuthCodeResponse, Error>) -> Void)
{
let verifier = getVerifier()!
let challenge = getChallenge(for: verifier)!

let authorize = URL(string: "authorize", relativeTo: Constants.url)!
var components = URLComponents(url: authorize, resolvingAgainstBaseURL: true)!
var items: [URLQueryItem] = []
var entries: [String: String] = [:]

entries["scope"] = "openid profile email offline_access"
entries["client_id"] = Constants.clientId
entries["response_type"] = "code"
if case .connection(let string) = settings {
entries["connection"] = string
}
entries["redirect_uri"] = Constants.redirectUri.absoluteString
entries["state"] = generateDefaultState()
entries["audience"] = "https://auth0-jwt-authorizer"
entries.forEach { items.append(URLQueryItem(name: $0, value: $1)) }
components.queryItems = items
components.percentEncodedQuery = components.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B")

let url = components.url!
let session = ASWebAuthenticationSession(
url: url,
callbackURLScheme: Constants.redirectUri.scheme!) { url, error in
if let error {
completion(.failure(error))
return
}

if let url {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let code = components!.queryItems!.first(where: { $0.name == "code"})
if let code {
self.exchangeAuthorizationCodeForTokens(authorizationCode: code.value!, verifier: verifier) { result in
completion(result)
}
} else {
completion(.failure(LoginError.noCode))
}
} else {
completion(.failure(LoginError.noUrl))
}
}
session.presentationContextProvider = PresentationContextProvider.shared
session.start()
}

private static func exchangeAuthorizationCodeForTokens(
authorizationCode: String,
verifier: String,
completion: @escaping (Result<AuthCodeResponse, Error>) -> Void)
{
let url = URL(string: "oauth/token", relativeTo: Constants.url)!

let parameters = [
"grant_type": "authorization_code",
"code_verifier": verifier,
"client_id": Constants.clientId,
"code": authorizationCode,
"redirect_uri": Constants.redirectUri.absoluteString,
]

var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try! JSONSerialization.data(withJSONObject: parameters, options: [])

URLSession(configuration: URLSessionConfiguration.ephemeral).getAuthDataWith(request, completion: completion)
}

private static func getVerifier() -> String? {
let data = Data(count: 32)
var tempData = data
_ = tempData.withUnsafeMutableBytes {
SecRandomCopyBytes(kSecRandomDefault, data.count, $0.baseAddress!)
}
return tempData.a0_encodeBase64URLSafe()
}

private static func getChallenge(for verifier: String) -> String? {
guard let data = verifier.data(using: .utf8) else { return nil }

var buffer = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
_ = data.withUnsafeBytes {
CC_SHA256($0.baseAddress, CC_LONG(data.count), &buffer)
}
return Data(buffer).a0_encodeBase64URLSafe()
}

private static func generateDefaultState() -> String {
let data = Data(count: 32)
var tempData = data

let result = tempData.withUnsafeMutableBytes {
SecRandomCopyBytes(kSecRandomDefault, data.count, $0.baseAddress!)
}

guard result == 0, let state = tempData.a0_encodeBase64URLSafe()
else { return UUID().uuidString.replacingOccurrences(of: "-", with: "") }

return state
}
}

private class PresentationContextProvider: NSObject, ASWebAuthenticationPresentationContextProviding {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might want to make this configurable so users can pass in their own ASWebAuthenticationPresentationContextProviding

fileprivate static let shared = PresentationContextProvider()

func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
if
let windowScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene,
let window = windowScene.windows.first(where: \.isKeyWindow) {
return window
}
return ASPresentationAnchor()
}
}

extension Data {
fileprivate func a0_encodeBase64URLSafe() -> String? {
return self
.base64EncodedString(options: [])
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
Loading
Loading