├── .github ├── CODEOWNERS ├── workflows │ ├── dependent-issues.yml │ ├── semantic-commit.yml │ └── ci.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── .gitignore ├── login-Example ├── login-Example │ ├── Assets.xcassets │ │ ├── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── SceneDelegate.swift │ ├── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard │ ├── AppDelegate.swift │ ├── Info.plist │ └── ViewController.swift └── login-Example.xcodeproj │ ├── xcshareddata │ └── xcschemes │ │ └── login-Example.xcscheme │ └── project.pbxproj ├── Package.resolved ├── file-header-template.txt ├── Sources └── InfomaniakLogin │ ├── Model │ ├── ApiDeleteToken.swift │ ├── LoginApiError.swift │ └── ApiToken.swift │ ├── InfomaniakLoginError.swift │ ├── Config.swift │ ├── DeleteAccountViewController.swift │ ├── WebViewController.swift │ ├── Networking │ └── InfomaniakNetworkLogin.swift │ └── InfomaniakLogin.swift ├── Tests └── InfomaniakLoginTests │ ├── XCTestManifests.swift │ └── InfomaniakLoginTests.swift ├── Package.swift ├── README.md └── LICENSE /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @Infomaniak/ios 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | 7 | .swiftpm/ 8 | DerivedData/ 9 | -------------------------------------------------------------------------------- /login-Example/login-Example/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "ios-dependency-injection", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/Infomaniak/ios-dependency-injection", 7 | "state" : { 8 | "revision" : "8dc9e67e6d3d9f4f5bd02d693a7ce1f93b125bcd", 9 | "version" : "2.0.1" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /file-header-template.txt: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | Copyright 2023 Infomaniak Network SA 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | */ 17 | -------------------------------------------------------------------------------- /.github/workflows/dependent-issues.yml: -------------------------------------------------------------------------------- 1 | name: Dependent Issues 2 | 3 | on: 4 | issues: 5 | types: 6 | - opened 7 | - edited 8 | - closed 9 | - reopened 10 | pull_request_target: 11 | types: 12 | - opened 13 | - edited 14 | - closed 15 | - reopened 16 | # Makes sure we always add status check for PRs. Useful only if 17 | # this action is required to pass before merging. Otherwise, it 18 | # can be removed. 19 | - synchronize 20 | 21 | jobs: 22 | check: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: z0al/dependent-issues@v1 26 | env: 27 | # (Required) The token to use to make API calls to GitHub. 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | -------------------------------------------------------------------------------- /Sources/InfomaniakLogin/Model/ApiDeleteToken.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 Infomaniak Network SA 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import Foundation 18 | 19 | public final class ApiDeleteToken: Codable, Sendable { 20 | let result: String 21 | let error: String? 22 | let data: Bool? 23 | } 24 | -------------------------------------------------------------------------------- /Tests/InfomaniakLoginTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 Infomaniak Network SA 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import XCTest 18 | 19 | #if !canImport(ObjectiveC) 20 | public func allTests() -> [XCTestCaseEntry] { 21 | return [ 22 | testCase(InfomaniakLoginTests.allTests), 23 | ] 24 | } 25 | #endif 26 | -------------------------------------------------------------------------------- /.github/workflows/semantic-commit.yml: -------------------------------------------------------------------------------- 1 | name: 'PR and Commit Message Check' 2 | on: 3 | pull_request_target: 4 | types: 5 | - opened 6 | - edited 7 | - reopened 8 | - synchronize 9 | 10 | jobs: 11 | check-commit-message: 12 | name: Check Commit Message 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check Commit Message 16 | uses: gsactions/commit-message-checker@16fa2d5de096ae0d35626443bcd24f1e756cafee #2.0.0 17 | with: 18 | pattern: '^(Merge .+|((feat|fix|chore|docs|style|refactor|perf|ci|test)(\(.+\))?: [A-Z0-9].+[^.\s])$)' 19 | error: 'Commit messages and PR title should match conventional commit convention and start with an uppercase.' 20 | excludeDescription: 'true' 21 | excludeTitle: 'false' 22 | checkAllCommitMessages: 'true' 23 | accessToken: ${{ secrets.GITHUB_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | 12 | 13 | **Is your feature request related to a problem? Please describe.** 14 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 15 | 16 | **Describe the solution you'd like** 17 | A clear and concise description of what you want to happen. 18 | 19 | **Describe alternatives you've considered** 20 | A clear and concise description of any alternative solutions or features you've considered. 21 | 22 | **Additional context** 23 | Add any other context or screenshots about the feature request here. 24 | -------------------------------------------------------------------------------- /Tests/InfomaniakLoginTests/InfomaniakLoginTests.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 Infomaniak Network SA 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | @testable import InfomaniakLogin 18 | import XCTest 19 | 20 | final class InfomaniakLoginTests: XCTestCase { 21 | func testExample() { 22 | // GIVEN 23 | let error = InfomaniakLoginError.accessDenied 24 | 25 | // THEN 26 | XCTAssertNotNil(error) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:6.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "InfomaniakLogin", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macOS(.v10_15) 11 | ], 12 | products: [ 13 | .library( 14 | name: "InfomaniakLogin", 15 | targets: ["InfomaniakLogin"] 16 | ), 17 | ], 18 | dependencies: [ 19 | .package(url: "https://github.com/Infomaniak/ios-dependency-injection", .upToNextMajor(from: "2.0.0")), 20 | ], 21 | targets: [ 22 | .target( 23 | name: "InfomaniakLogin", 24 | dependencies: [ 25 | .product(name: "InfomaniakDI", package: "ios-dependency-injection"), 26 | ] 27 | ), 28 | .testTarget( 29 | name: "InfomaniakLoginTests", 30 | dependencies: ["InfomaniakLogin"] 31 | ), 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 12 | 13 | **Description** 14 | A clear and concise description of what the bug is. 15 | 16 | **Steps to reproduce** 17 | Steps to reproduce the behavior: 18 | 1. Go to '...' 19 | 2. Click on '....' 20 | 3. Scroll down to '....' 21 | 4. See error 22 | 23 | **Expected behavior** 24 | A clear and concise description of what you expected to happen. 25 | 26 | **Screenshots** 27 | If applicable, add screenshots to help explain your problem. 28 | 29 | **Smartphone (please complete the following information):** 30 | - Device: [e.g. iPhone 12] 31 | - iOS version: [e.g. iOS 14.0] 32 | - App version: [e.g. 4.0.1] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /Sources/InfomaniakLogin/Model/LoginApiError.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 Infomaniak Network SA 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import Foundation 18 | 19 | /// An api error for `InfomaniakLogin` form 20 | @objc public class LoginApiError: NSObject, Codable { 21 | @objc public let error: String 22 | @objc public let errorDescription: String? 23 | 24 | enum CodingKeys: String, CodingKey { 25 | case error 26 | case errorDescription = "error_description" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI workflow 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | 7 | concurrency: 8 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | build_and_test_iOS: 13 | name: Build and Test project on iOS 14 | runs-on: [ self-hosted, iOS ] 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v5.0.0 19 | - name: Build 20 | run: xcodebuild -scheme InfomaniakLogin build -destination "platform=iOS Simulator,name=iPhone 17 Pro,OS=latest" 21 | - name: Test 22 | run: xcodebuild -scheme InfomaniakLogin test -destination "platform=iOS Simulator,name=iPhone 17 Pro,OS=latest" 23 | 24 | build_and_test_macOS: 25 | name: Build and Test project on macOS 26 | runs-on: [ self-hosted, macOS ] 27 | 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v5.0.0 31 | - name: Build 32 | run: swift build 33 | - name: Test 34 | run: swift test 35 | -------------------------------------------------------------------------------- /login-Example/login-Example/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Infomaniak Network SA 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import InfomaniakDI 18 | import InfomaniakLogin 19 | import UIKit 20 | 21 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 22 | var window: UIWindow? 23 | 24 | @LazyInjectService var loginService: InfomaniakLoginable 25 | 26 | func scene(_ scene: UIScene, 27 | willConnectTo session: UISceneSession, 28 | options connectionOptions: UIScene.ConnectionOptions) { 29 | guard let _ = (scene as? UIWindowScene) else { return } 30 | } 31 | 32 | func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { 33 | if let url = URLContexts.first?.url { 34 | // Handle URL 35 | _ = loginService.handleRedirectUri(url: url) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/InfomaniakLogin/InfomaniakLoginError.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 Infomaniak Network SA 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import Foundation 18 | 19 | public enum InfomaniakLoginError: LocalizedError { 20 | public typealias HTTPStatusCode = Int 21 | public typealias AccessToken = String 22 | 23 | case accessDenied 24 | case navigationFailed(Error) 25 | case navigationCancelled(HTTPStatusCode?, URL?) 26 | case invalidAccessToken(AccessToken?) 27 | case invalidUrl 28 | case noRefreshToken 29 | case unknownNetworkError 30 | 31 | public var errorDescription: String? { 32 | switch self { 33 | case .accessDenied: 34 | return "Access denied" 35 | case .navigationFailed: 36 | return "Navigation failed" 37 | case .navigationCancelled: 38 | return "Navigation cancelled" 39 | case .invalidAccessToken: 40 | return "Invalid access token" 41 | case .invalidUrl: 42 | return "Invalid url" 43 | case .noRefreshToken: 44 | return "No refresh token" 45 | case .unknownNetworkError: 46 | return "Unknown network error" 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /login-Example/login-Example/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /login-Example/login-Example/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /login-Example/login-Example/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Infomaniak Network SA 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import InfomaniakDI 18 | import InfomaniakLogin 19 | import UIKit 20 | 21 | @UIApplicationMain 22 | class AppDelegate: UIResponder, UIApplicationDelegate { 23 | static let loginBaseURL = URL(string: "https://login.infomaniak.com/")! // Preprod is https://login.preprod.dev.infomaniak.ch/ 24 | 25 | func application(_ application: UIApplication, 26 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { 27 | setupDI() 28 | 29 | return true 30 | } 31 | 32 | // Needed if there is no SceneDelegate.swift file 33 | var window: UIWindow? 34 | 35 | func application(_ application: UIApplication, 36 | open url: URL, sourceApplication: 37 | String?, annotation: Any) -> Bool { 38 | let service = InjectService().wrappedValue 39 | return service.handleRedirectUri(url: url) 40 | } 41 | 42 | func setupDI() { 43 | let clientId = "9473D73C-C20F-4971-9E10-D957C563FA68" 44 | let redirectUri = "com.infomaniak.drive://oauth2redirect" 45 | let config = InfomaniakLogin.Config( 46 | clientId: clientId, 47 | loginURL: AppDelegate.loginBaseURL, 48 | redirectURI: redirectUri, 49 | accessType: .none 50 | ) 51 | /// The `InfomaniakLoginable` interface hides the concrete type `InfomaniakLogin` 52 | SimpleResolver.sharedResolver.store(factory: Factory(type: InfomaniakLoginable.self) { _, _ in 53 | return InfomaniakLogin(config: config) 54 | }) 55 | 56 | /// The `InfomaniakNetworkLoginable` interface hides the concrete type `InfomaniakNetworkLogin` 57 | SimpleResolver.sharedResolver.store(factory: Factory(type: InfomaniakNetworkLoginable.self) { _, resolver in 58 | return InfomaniakNetworkLogin(config: config) 59 | }) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /login-Example/login-Example/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleURLTypes 20 | 21 | 22 | CFBundleTypeRole 23 | None 24 | CFBundleURLName 25 | oauth2redirect 26 | CFBundleURLSchemes 27 | 28 | com.infomaniak.drive 29 | 30 | 31 | 32 | CFBundleVersion 33 | 1 34 | LSRequiresIPhoneOS 35 | 36 | UIApplicationSceneManifest 37 | 38 | UIApplicationSupportsMultipleScenes 39 | 40 | UISceneConfigurations 41 | 42 | UIWindowSceneSessionRoleApplication 43 | 44 | 45 | UISceneConfigurationName 46 | Default Configuration 47 | UISceneDelegateClassName 48 | $(PRODUCT_MODULE_NAME).SceneDelegate 49 | UISceneStoryboardFile 50 | Main 51 | 52 | 53 | 54 | 55 | UILaunchStoryboardName 56 | LaunchScreen 57 | UIMainStoryboardFile 58 | Main 59 | UIRequiredDeviceCapabilities 60 | 61 | armv7 62 | 63 | UISupportedInterfaceOrientations 64 | 65 | UIInterfaceOrientationPortrait 66 | UIInterfaceOrientationLandscapeLeft 67 | UIInterfaceOrientationLandscapeRight 68 | 69 | UISupportedInterfaceOrientations~ipad 70 | 71 | UIInterfaceOrientationPortrait 72 | UIInterfaceOrientationPortraitUpsideDown 73 | UIInterfaceOrientationLandscapeLeft 74 | UIInterfaceOrientationLandscapeRight 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /Sources/InfomaniakLogin/Config.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 Infomaniak Network SA 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import Foundation 18 | 19 | @frozen public enum AccessType: String { 20 | /// When using `offline` accessToken has an expiration date and a refresh token is returned by the back-end 21 | case offline 22 | } 23 | 24 | @frozen public enum ResponseType: String { 25 | case code 26 | } 27 | 28 | public extension InfomaniakLogin { 29 | @frozen struct Config { 30 | public let clientId: String 31 | public let loginURL: URL 32 | public let redirectURI: String 33 | public let responseType: ResponseType 34 | public let accessType: AccessType? 35 | public let hashMode: String 36 | public let hashModeShort: String 37 | 38 | /// Initializes an OAuth2 configuration for a given Infomaniak client app 39 | /// 40 | /// - Parameters: 41 | /// - clientId: An identifier provided by the backend. 42 | /// - loginURL: Base URL for login calls, defaults to production. Can be replaced with preprod. 43 | /// - redirectURI: Should match the app bundle ID. 44 | /// - responseType: The response type, currently only supports `.code`. 45 | /// - accessType: Use `.offline` for refresh token-based auth, `.none` or `nil` for non expiring token. 46 | /// - hashMode: The hash mode, defaults to "SHA-256". 47 | /// - hashModeShort: A short version of the hash mode, defaults to "S256". 48 | public init( 49 | clientId: String, 50 | loginURL: URL = URL(string: "https://login.infomaniak.com/")!, 51 | redirectURI: String = "\(Bundle.main.bundleIdentifier ?? "")://oauth2redirect", 52 | responseType: ResponseType = .code, 53 | accessType: AccessType? = .offline, 54 | hashMode: String = "SHA-256", 55 | hashModeShort: String = "S256" 56 | ) { 57 | self.clientId = clientId 58 | self.loginURL = loginURL 59 | self.redirectURI = redirectURI 60 | self.responseType = responseType 61 | self.accessType = accessType 62 | self.hashMode = hashMode 63 | self.hashModeShort = hashModeShort 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /login-Example/login-Example.xcodeproj/xcshareddata/xcschemes/login-Example.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Sources/InfomaniakLogin/Model/ApiToken.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 Infomaniak Network SA 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import Foundation 18 | 19 | @frozen public struct ApiToken: Codable, Sendable { 20 | public let accessToken: String 21 | public let refreshToken: String? 22 | public let scope: String 23 | public let tokenType: String 24 | public let userId: Int 25 | public let expiresIn: Int? 26 | public let expirationDate: Date? 27 | 28 | enum CodingKeys: String, CodingKey { 29 | case accessToken = "access_token" 30 | case expiresIn = "expires_in" 31 | case refreshToken = "refresh_token" 32 | case tokenType = "token_type" 33 | case userId = "user_id" 34 | case scope 35 | case expirationDate 36 | } 37 | 38 | public init(from decoder: Decoder) throws { 39 | let values = try decoder.container(keyedBy: CodingKeys.self) 40 | accessToken = try values.decode(String.self, forKey: .accessToken) 41 | let maybeExpiresIn = try values.decodeIfPresent(Int.self, forKey: .expiresIn) 42 | expiresIn = maybeExpiresIn 43 | refreshToken = try values.decodeIfPresent(String.self, forKey: .refreshToken) 44 | scope = try values.decode(String.self, forKey: .scope) 45 | tokenType = try values.decode(String.self, forKey: .tokenType) 46 | if let userId = try? values.decode(Int.self, forKey: .userId) { 47 | self.userId = userId 48 | } else if let rawUserId = try? values.decode(String.self, forKey: .userId), 49 | let userId = Int(rawUserId) { 50 | self.userId = userId 51 | } else { 52 | throw DecodingError.dataCorruptedError( 53 | forKey: .userId, 54 | in: values, 55 | debugDescription: "user_id is not an Int or String convertible to Int" 56 | ) 57 | } 58 | 59 | if let maybeExpiresIn { 60 | let newExpirationDate = Date().addingTimeInterval(TimeInterval(Double(maybeExpiresIn))) 61 | expirationDate = try values.decodeIfPresent(Date.self, forKey: .expirationDate) ?? newExpirationDate 62 | } else { 63 | expirationDate = nil 64 | } 65 | } 66 | 67 | public init( 68 | accessToken: String, 69 | expiresIn: Int, 70 | refreshToken: String, 71 | scope: String, 72 | tokenType: String, 73 | userId: Int, 74 | expirationDate: Date 75 | ) { 76 | self.accessToken = accessToken 77 | self.expiresIn = expiresIn 78 | self.refreshToken = refreshToken 79 | self.scope = scope 80 | self.tokenType = tokenType 81 | self.userId = userId 82 | self.expirationDate = expirationDate 83 | } 84 | } 85 | 86 | // MARK: - Token Logging 87 | 88 | public extension ApiToken { 89 | var truncatedAccessToken: String { 90 | truncateToken(accessToken) 91 | } 92 | 93 | var truncatedRefreshToken: String { 94 | guard let refreshToken else { return "" } 95 | return truncateToken(refreshToken) 96 | } 97 | 98 | internal func truncateToken(_ token: String) -> String { 99 | String(token.prefix(4) + "-*****-" + token.suffix(4)) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # InfomaniakLogin 2 | 3 | Library to simplify login process with Infomaniak OAuth 2.0 protocol 4 | 5 | ## Installation 6 | 7 | 1. In your Xcode project, go to: File > Swift Packages > Add Package Dependency… 8 | 2. Enter the package URL: `git@github.com:Infomaniak/ios-login.git` or `https://github.com/Infomaniak/ios-login.git` 9 | 10 | ## Usage 11 | 12 | ### Shared setup 13 | 1. Add `import InfomaniakLogin` and `import InfomaniakDI` at the top of your AppDelegate 14 | 2. Add this method and call it asap in the `func application(didFinishLaunchingWithOptions:)` 15 | ```swift 16 | func setupDI() { 17 | do { 18 | /// The `InfomaniakLoginable` interface hides the concrete type `InfomaniakLogin` 19 | try SimpleResolver.sharedResolver.store(factory: Factory(type: InfomaniakLoginable.self) { _, _ in 20 | let clientId = "9473D73C-C20F-4971-9E10-D957C563FA68" 21 | let redirectUri = "com.infomaniak.drive://oauth2redirect" 22 | let login = InfomaniakLogin(clientId: clientId, redirectUri: redirectUri) 23 | return login 24 | }) 25 | 26 | /// Chained resolution, the `InfomaniakTokenable` interface uses the `InfomaniakLogin` object as well 27 | try SimpleResolver.sharedResolver.store(factory: Factory(type: InfomaniakTokenable.self) { _, resolver in 28 | return try resolver.resolve(type: InfomaniakLoginable.self, 29 | forCustomTypeIdentifier: nil, 30 | factoryParameters: nil, 31 | resolver: resolver) 32 | }) 33 | } catch { 34 | fatalError("unexpected \(error)") 35 | } 36 | } 37 | ``` 38 | 39 | ### With SFSafariViewController 40 | 41 | **If your project has a `SceneDelegate.swift` file:** 42 | 1. Add `import InfomaniakLogin` and `import InfomaniakDI` at the top of the file 43 | 2. Add this property `@InjectService var loginService: InfomaniakLoginable` 44 | 3. Add this method: 45 | ```swift 46 | func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { 47 | if let url = URLContexts.first?.url { 48 | // Handle URL 49 | _ = loginService.handleRedirectUri(url: url) 50 | } 51 | } 52 | ``` 53 | 54 | **If your project doesn't have a `SceneDelegate.swift` file:** 55 | 1. Go to your AppDelegate 56 | 2. Initialise a `UIWindow` variable inside your AppDelegate: 57 | ```swift 58 | var window: UIWindow? 59 | ``` 60 | 3. Add this method inside your AppDelegate: 61 | ```swift 62 | func application(_ application: UIApplication, open url: URL, sourceApplication: String?, annotation: Any) -> Bool { 63 | let service = InjectService().wrappedValue 64 | return service.handleRedirectUri(url: url) 65 | } 66 | ``` 67 | 68 | **Final part:** 69 | 70 | You can now use it where you want by adding the injected property like so `@InjectService var loginService: InfomaniakLoginable` 71 | and adding the `InfomaniakLoginDelegate` protocol to the class who needs it: 72 | 73 | ````swift 74 | func didCompleteLoginWith(code: String, verifier: String) { 75 | loginService.getApiTokenUsing(code: code, codeVerifier: verifier) { (token, error) in 76 | // Save the token 77 | } 78 | } 79 | 80 | func didFailLoginWith(error: Error) { 81 | // Handle the error 82 | } 83 | ```` 84 | 85 | And you can finally use the login fonction, for example with a button, by writing: 86 | 87 | ````swift 88 | @IBAction func login(_ sender: UIButton) { 89 | loginService.loginFrom(viewController: self, delegate: self, clientId: clientId, redirectUri: redirectUri) 90 | } 91 | ```` 92 | 93 | With these arguments: 94 | - `clientId`: The client ID of the app 95 | - `redirectUri`: The redirection URL after a successful login (in order to handle the codes) 96 | 97 | ### With WKWebView 98 | 99 | First, add `import InfomaniakLogin` at the top of the file. 100 | 101 | Also add `import InfomaniakDI` 102 | 103 | Then ad the injected service property like so `@InjectService var loginService: InfomaniakLoginable` 104 | 105 | You can now use it where you want by adding the `InfomaniakLoginDelegate` protocol to the class who needs it: 106 | 107 | ````swift 108 | func didCompleteLoginWith(code: String, verifier: String) { 109 | InfomaniakLogin.getApiTokenUsing(code: code, codeVerifier: verifier) { (token, error) in 110 | // Save the token 111 | } 112 | } 113 | 114 | func didFailLoginWith(error: Error) { 115 | // Handle the error 116 | } 117 | ```` 118 | 119 | And you can finally use the login fonction, for example with a button, by writing: 120 | 121 | ````swift 122 | @IBAction func login(_ sender: UIButton) { 123 | InfomaniakLogin.webviewLoginFrom(viewController: self, delegate: self, clientId: clientId, redirectUri: redirectUri) 124 | } 125 | ```` 126 | 127 | With these arguments: 128 | - `clientId`: The client ID of the app 129 | - `redirectUri`: The redirection URL after a successful login (in order to handle the codes) 130 | 131 | But if you are using the Web View method, you can also use this method: 132 | 133 | ````swift 134 | InfomaniakLogin.setupWebviewNavbar(title: nil, color: .red, clearCookie: true) 135 | ```` 136 | 137 | With these arguments: 138 | - `title`: The title that will be shown in the navigation bar 139 | - `color`: The color of the navigation bar 140 | - `clearCookie`: 141 | - If `true`, the cookie will be deleted when the Web View is closed 142 | - If `false`, the cookie won't be deleted when the Web View is closed 143 | 144 | ## License 145 | 146 | Copyright 2023 Infomaniak 147 | 148 | Licensed under the Apache License, Version 2.0 (the "License"); 149 | you may not use this file except in compliance with the License. 150 | You may obtain a copy of the License at 151 | 152 | http://www.apache.org/licenses/LICENSE-2.0 153 | 154 | Unless required by applicable law or agreed to in writing, software 155 | distributed under the License is distributed on an "AS IS" BASIS, 156 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 157 | See the License for the specific language governing permissions and 158 | limitations under the License. 159 | -------------------------------------------------------------------------------- /Sources/InfomaniakLogin/DeleteAccountViewController.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 Infomaniak Network SA 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | #if canImport(UIKit) 18 | import InfomaniakDI 19 | import UIKit 20 | import WebKit 21 | 22 | public protocol DeleteAccountDelegate: AnyObject { 23 | func didCompleteDeleteAccount() 24 | func didFailDeleteAccount(error: InfomaniakLoginError) 25 | } 26 | 27 | public class DeleteAccountViewController: UIViewController { 28 | @LazyInjectService var infomaniakLogin: InfomaniakLoginable 29 | 30 | private var webView: WKWebView! 31 | private var progressView: UIProgressView! 32 | public var navBarColor: UIColor? 33 | public var navBarButtonColor: UIColor? 34 | 35 | private var progressObserver: NSKeyValueObservation? 36 | private var accountDeleted = false 37 | 38 | public weak var delegate: DeleteAccountDelegate? 39 | public var accessToken: String? 40 | 41 | override public func loadView() { 42 | super.loadView() 43 | setupWebView() 44 | setupNavBar() 45 | setupProgressView() 46 | } 47 | 48 | override public func viewDidLoad() { 49 | super.viewDidLoad() 50 | 51 | if let url = Constants.autologinUrl(to: Constants.deleteAccountURL) { 52 | if let accessToken = accessToken { 53 | var request = URLRequest(url: url) 54 | request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") 55 | webView.load(request) 56 | } else { 57 | delegate?.didFailDeleteAccount(error: .invalidAccessToken(accessToken)) 58 | dismiss(animated: true) 59 | } 60 | } else { 61 | delegate?.didFailDeleteAccount(error: .invalidUrl) 62 | dismiss(animated: true) 63 | } 64 | } 65 | 66 | public static func instantiateInViewController( 67 | delegate: DeleteAccountDelegate? = nil, 68 | accessToken: String?, 69 | navBarColor: UIColor? = nil, 70 | navBarButtonColor: UIColor? = nil 71 | ) -> UINavigationController { 72 | let deleteAccountViewController = DeleteAccountViewController() 73 | deleteAccountViewController.delegate = delegate 74 | deleteAccountViewController.accessToken = accessToken 75 | deleteAccountViewController.navBarColor = navBarColor 76 | deleteAccountViewController.navBarButtonColor = navBarButtonColor 77 | 78 | let navigationController = UINavigationController(rootViewController: deleteAccountViewController) 79 | return navigationController 80 | } 81 | 82 | private func setupNavBar() { 83 | let navigationAppearance = UINavigationBarAppearance() 84 | navigationAppearance.configureWithDefaultBackground() 85 | if let navBarColor = navBarColor { 86 | navigationAppearance.backgroundColor = navBarColor 87 | } 88 | navigationController?.navigationBar.standardAppearance = navigationAppearance 89 | 90 | let backButton = UIBarButtonItem(barButtonSystemItem: .stop, target: self, action: #selector(close)) 91 | if let navBarButtonColor = navBarButtonColor { 92 | backButton.tintColor = navBarButtonColor 93 | } 94 | navigationItem.leftBarButtonItem = backButton 95 | } 96 | 97 | private func setupProgressView() { 98 | guard let navigationBar = navigationController?.navigationBar else { return } 99 | 100 | progressView = UIProgressView(progressViewStyle: .default) 101 | progressView.translatesAutoresizingMaskIntoConstraints = false 102 | navigationBar.addSubview(progressView) 103 | 104 | progressView.isHidden = true 105 | 106 | NSLayoutConstraint.activate([ 107 | progressView.leadingAnchor.constraint(equalTo: navigationBar.leadingAnchor), 108 | progressView.trailingAnchor.constraint(equalTo: navigationBar.trailingAnchor), 109 | progressView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor), 110 | progressView.heightAnchor.constraint(equalToConstant: 2.0) 111 | ]) 112 | 113 | progressObserver = webView.observe(\.estimatedProgress, options: .new) { [weak self] _, value in 114 | Task { @MainActor [weak self] in 115 | guard let newValue = value.newValue else { return } 116 | self?.progressView.isHidden = newValue == 1 117 | self?.progressView.setProgress(Float(newValue), animated: true) 118 | } 119 | } 120 | } 121 | 122 | private func setupWebView() { 123 | let webConfiguration = WKWebViewConfiguration() 124 | webView = WKWebView(frame: .zero, configuration: webConfiguration) 125 | webView.translatesAutoresizingMaskIntoConstraints = false 126 | webView.navigationDelegate = self 127 | view = webView 128 | } 129 | 130 | @objc func close() { 131 | dismiss(animated: true) 132 | } 133 | } 134 | 135 | // MARK: - WKNavigationDelegate 136 | 137 | extension DeleteAccountViewController: WKNavigationDelegate { 138 | public func webView( 139 | _ webView: WKWebView, 140 | decidePolicyFor navigationAction: WKNavigationAction, 141 | decisionHandler: @MainActor (WKNavigationActionPolicy) -> Void 142 | ) { 143 | if let url = navigationAction.request.url { 144 | let urlString = url.absoluteString 145 | if url.host == infomaniakLogin.config.loginURL.host { 146 | decisionHandler(.allow) 147 | dismiss(animated: true) 148 | if !accountDeleted { 149 | delegate?.didCompleteDeleteAccount() 150 | accountDeleted = true 151 | } 152 | return 153 | } 154 | // Sometimes login redirects to about:blank 155 | if urlString.contains("infomaniak.com") || urlString.contains("about:blank") { 156 | decisionHandler(.allow) 157 | return 158 | } 159 | } 160 | 161 | decisionHandler(.cancel) 162 | delegate?.didFailDeleteAccount(error: .navigationCancelled(nil, nil)) 163 | dismiss(animated: true) 164 | } 165 | 166 | public func webView( 167 | _ webView: WKWebView, 168 | decidePolicyFor navigationResponse: WKNavigationResponse, 169 | decisionHandler: @MainActor (WKNavigationResponsePolicy) -> Void 170 | ) { 171 | guard let statusCode = (navigationResponse.response as? HTTPURLResponse)?.statusCode else { 172 | decisionHandler(.allow) 173 | return 174 | } 175 | 176 | if statusCode == 200 { 177 | decisionHandler(.allow) 178 | } else { 179 | decisionHandler(.cancel) 180 | delegate?.didFailDeleteAccount(error: .navigationCancelled(statusCode, navigationResponse.response.url)) 181 | dismiss(animated: true) 182 | } 183 | } 184 | 185 | public func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { 186 | delegate?.didFailDeleteAccount(error: .navigationFailed(error)) 187 | dismiss(animated: true) 188 | } 189 | } 190 | #endif 191 | -------------------------------------------------------------------------------- /Sources/InfomaniakLogin/WebViewController.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 Infomaniak Network SA 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | #if canImport(UIKit) 18 | import InfomaniakDI 19 | import UIKit 20 | import WebKit 21 | 22 | class WebViewController: UIViewController, WKUIDelegate { 23 | @LazyInjectService private var infomaniakLogin: InfomaniakLoginable 24 | 25 | private let clearCookie: Bool 26 | private let redirectUri: String 27 | private let urlRequest: URLRequest 28 | 29 | var navBarButtonColor: UIColor? 30 | var navBarColor: UIColor? 31 | var navBarTitle: String? 32 | var navBarTitleColor: UIColor? 33 | 34 | var timeOutMessage: String? 35 | var timer: Timer? 36 | 37 | private lazy var webView: WKWebView = { 38 | let webConfiguration = WKWebViewConfiguration() 39 | let webView = WKWebView(frame: .zero, configuration: webConfiguration) 40 | webView.navigationDelegate = self 41 | webView.uiDelegate = self 42 | 43 | return webView 44 | }() 45 | 46 | private let progressView = UIProgressView(progressViewStyle: .default) 47 | private var estimatedProgressObserver: NSKeyValueObservation? 48 | 49 | private let maxLoadingTime = 20.0 50 | 51 | init(clearCookie: Bool, redirectUri: String, urlRequest: URLRequest) { 52 | self.clearCookie = clearCookie 53 | self.redirectUri = redirectUri 54 | self.urlRequest = urlRequest 55 | super.init(nibName: nil, bundle: nil) 56 | } 57 | 58 | @available(*, unavailable) 59 | required init?(coder: NSCoder) { 60 | fatalError("init(coder:) has not been implemented") 61 | } 62 | 63 | override func loadView() { 64 | super.loadView() 65 | view = webView 66 | } 67 | 68 | override func viewDidLoad() { 69 | super.viewDidLoad() 70 | setupNavBar() 71 | setupProgressView() 72 | setupEstimatedProgressObserver() 73 | webView.load(urlRequest) 74 | timer = Timer.scheduledTimer(timeInterval: maxLoadingTime, 75 | target: self, 76 | selector: #selector(timeOutError), 77 | userInfo: nil, 78 | repeats: false) 79 | } 80 | 81 | private func setupProgressView() { 82 | guard let navigationBar = navigationController?.navigationBar else { return } 83 | 84 | progressView.translatesAutoresizingMaskIntoConstraints = false 85 | navigationBar.addSubview(progressView) 86 | 87 | progressView.isHidden = true 88 | 89 | NSLayoutConstraint.activate([ 90 | progressView.leadingAnchor.constraint(equalTo: navigationBar.leadingAnchor), 91 | progressView.trailingAnchor.constraint(equalTo: navigationBar.trailingAnchor), 92 | 93 | progressView.bottomAnchor.constraint(equalTo: navigationBar.bottomAnchor), 94 | progressView.heightAnchor.constraint(equalToConstant: 2.0) 95 | ]) 96 | } 97 | 98 | private func setupEstimatedProgressObserver() { 99 | estimatedProgressObserver = webView.observe(\.estimatedProgress, options: [.new]) { [weak self] webView, _ in 100 | Task { @MainActor [weak self] in 101 | self?.progressView.progress = Float(webView.estimatedProgress) 102 | } 103 | } 104 | } 105 | 106 | func setupNavBar() { 107 | title = navBarTitle ?? "login.infomaniak.com" 108 | 109 | let navigationAppearance = UINavigationBarAppearance() 110 | navigationAppearance.configureWithDefaultBackground() 111 | if let navBarColor = navBarColor { 112 | navigationAppearance.backgroundColor = navBarColor 113 | } 114 | if let navBarTitleColor = navBarTitleColor { 115 | navigationAppearance.titleTextAttributes = [NSAttributedString.Key.foregroundColor: navBarTitleColor] 116 | } 117 | navigationController?.navigationBar.standardAppearance = navigationAppearance 118 | 119 | let backButton = UIBarButtonItem(barButtonSystemItem: .stop, target: self, action: #selector(doneButtonPressed)) 120 | if let navBarButtonColor = navBarButtonColor { 121 | backButton.tintColor = navBarButtonColor 122 | } 123 | navigationItem.leftBarButtonItem = backButton 124 | } 125 | 126 | @objc func doneButtonPressed() { 127 | dismiss(animated: true) 128 | } 129 | 130 | override func viewWillDisappear(_ animated: Bool) { 131 | super.viewWillDisappear(true) 132 | if clearCookie { 133 | cleanCookies() 134 | } 135 | } 136 | 137 | func cleanCookies() { 138 | WKWebsiteDataStore.default().fetchDataRecords(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes()) { records in 139 | for record in records { 140 | WKWebsiteDataStore.default().removeData(ofTypes: record.dataTypes, for: [record]) {} 141 | } 142 | } 143 | } 144 | 145 | @objc func timeOutError() { 146 | let alertController = UIAlertController( 147 | title: timeOutMessage ?? "Page Not Loading !", 148 | message: nil, 149 | preferredStyle: .alert 150 | ) 151 | alertController.addAction(UIAlertAction(title: "OK", style: .default, handler: { 152 | _ in 153 | self.dismiss(animated: true, completion: nil) 154 | })) 155 | present(alertController, animated: true, completion: nil) 156 | } 157 | } 158 | 159 | // MARK: - WKNavigationDelegate 160 | 161 | extension WebViewController: WKNavigationDelegate { 162 | func webView(_: WKWebView, didStartProvisionalNavigation _: WKNavigation!) { 163 | if progressView.isHidden { 164 | progressView.isHidden = false 165 | } 166 | UIView.animate(withDuration: 0.33, 167 | animations: { 168 | self.progressView.alpha = 1.0 169 | }) 170 | } 171 | 172 | public func webView( 173 | _ webView: WKWebView, 174 | decidePolicyFor navigationAction: WKNavigationAction, 175 | decisionHandler: @MainActor (WKNavigationActionPolicy) -> Swift.Void 176 | ) { 177 | if let host = navigationAction.request.url?.host, 178 | let configHost = urlRequest.url?.host { 179 | if host.contains(configHost) || host.contains("oauth2redirect") { 180 | decisionHandler(.allow) 181 | return 182 | } 183 | 184 | // We are trying to navigate to somewhere else than login but still on the infomaniak host. (eg. manager) 185 | if host.hasSuffix("infomaniak.com") { 186 | decisionHandler(.cancel) 187 | return 188 | } 189 | } 190 | 191 | decisionHandler(.allow) 192 | } 193 | 194 | func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) { 195 | if webView.url?.absoluteString.starts(with: redirectUri) ?? false { 196 | _ = infomaniakLogin.webviewHandleRedirectUri(url: webView.url!) 197 | } 198 | } 199 | 200 | func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { 201 | UIView.animate(withDuration: 0.33, 202 | animations: { 203 | self.progressView.alpha = 0.0 204 | }, 205 | completion: { isFinished in 206 | self.progressView.isHidden = isFinished 207 | }) 208 | } 209 | 210 | func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) { 211 | timer?.invalidate() 212 | } 213 | } 214 | #endif 215 | -------------------------------------------------------------------------------- /Sources/InfomaniakLogin/Networking/InfomaniakNetworkLogin.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 Infomaniak Network SA 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import Foundation 18 | 19 | /// Something that can keep the network stack authenticated 20 | public protocol InfomaniakNetworkLoginable { 21 | /// Get an api token async (callback on background thread) 22 | func getApiTokenUsing(code: String, codeVerifier: String, completion: @Sendable @escaping (Result) -> Void) 23 | 24 | /// Get an api token 25 | func apiTokenUsing(code: String, codeVerifier: String) async throws -> ApiToken 26 | 27 | /// Refresh api token async (callback on background thread) 28 | func refreshToken(token: ApiToken, completion: @Sendable @escaping (Result) -> Void) 29 | 30 | /// Refresh api token 31 | func refreshToken(token: ApiToken) async throws -> ApiToken 32 | 33 | /// Delete an api token async 34 | func deleteApiToken(token: ApiToken, completion: @Sendable @escaping (Result) -> Void) 35 | 36 | /// Delete an api token 37 | func deleteApiToken(token: ApiToken) async throws 38 | 39 | func derivateApiToken( 40 | using token: ApiToken, 41 | attestationToken: String, 42 | completion: @Sendable @escaping (Result) -> Void 43 | ) 44 | 45 | func derivateApiToken(using token: ApiToken, attestationToken: String) async throws -> ApiToken 46 | } 47 | 48 | public class InfomaniakNetworkLogin: InfomaniakNetworkLoginable { 49 | private let config: InfomaniakLogin.Config 50 | private let tokenApiURL: URL 51 | 52 | // MARK: Public 53 | 54 | public init(config: InfomaniakLogin.Config) { 55 | self.config = config 56 | tokenApiURL = config.loginURL.appendingPathComponent("token") 57 | } 58 | 59 | public func apiTokenUsing(code: String, codeVerifier: String) async throws -> ApiToken { 60 | return try await withCheckedThrowingContinuation { continuation in 61 | getApiTokenUsing(code: code, codeVerifier: codeVerifier) { result in 62 | continuation.resume(with: result) 63 | } 64 | } 65 | } 66 | 67 | public func getApiTokenUsing(code: String, 68 | codeVerifier: String, 69 | completion: @Sendable @escaping (Result) -> Void) { 70 | var request = URLRequest(url: tokenApiURL) 71 | 72 | let parameterDictionary: [String: Any] = [ 73 | "grant_type": "authorization_code", 74 | "client_id": config.clientId, 75 | "code": code, 76 | "code_verifier": codeVerifier, 77 | "redirect_uri": config.redirectURI 78 | ] 79 | request.httpMethod = "POST" 80 | request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 81 | request.httpBody = parameterDictionary.percentEncoded() 82 | 83 | getApiToken(request: request, completion: completion) 84 | } 85 | 86 | public func refreshToken(token: ApiToken) async throws -> ApiToken { 87 | return try await withCheckedThrowingContinuation { continuation in 88 | refreshToken(token: token) { result in 89 | continuation.resume(with: result) 90 | } 91 | } 92 | } 93 | 94 | public func refreshToken(token: ApiToken, completion: @Sendable @escaping (Result) -> Void) { 95 | guard let refreshToken = token.refreshToken else { 96 | completion(.failure(InfomaniakLoginError.noRefreshToken)) 97 | return 98 | } 99 | 100 | var request = URLRequest(url: tokenApiURL) 101 | 102 | var parameterDictionary: [String: Any] = [ 103 | "grant_type": "refresh_token", 104 | "client_id": config.clientId, 105 | "refresh_token": refreshToken 106 | ] 107 | 108 | if config.accessType == .none { 109 | parameterDictionary["duration"] = "infinite" 110 | } 111 | 112 | request.httpMethod = "POST" 113 | request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 114 | request.httpBody = parameterDictionary.percentEncoded() 115 | 116 | getApiToken(request: request, completion: completion) 117 | } 118 | 119 | public func derivateApiToken(using token: ApiToken, attestationToken: String) async throws -> ApiToken { 120 | return try await withCheckedThrowingContinuation { continuation in 121 | derivateApiToken(using: token, attestationToken: attestationToken) { result in 122 | continuation.resume(with: result) 123 | } 124 | } 125 | } 126 | 127 | public func derivateApiToken(using apiToken: ApiToken, 128 | attestationToken: String, 129 | completion: @Sendable @escaping (Result) -> Void) { 130 | var request = URLRequest(url: tokenApiURL) 131 | 132 | var parameterDictionary: [String: Any] = [ 133 | "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", 134 | "subject_token": apiToken.accessToken, 135 | "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", 136 | "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 137 | "client_assertion": attestationToken, 138 | "client_id": config.clientId, 139 | ] 140 | if config.accessType == .none { 141 | parameterDictionary["duration"] = "infinite" 142 | } 143 | 144 | request.httpMethod = "POST" 145 | request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") 146 | request.httpBody = parameterDictionary.percentEncoded() 147 | 148 | getApiToken(request: request, completion: completion) 149 | } 150 | 151 | public func deleteApiToken(token: ApiToken) async throws { 152 | return try await withCheckedThrowingContinuation { continuation in 153 | deleteApiToken(token: token) { result in 154 | continuation.resume(with: result) 155 | } 156 | } 157 | } 158 | 159 | public func deleteApiToken(token: ApiToken, completion: @Sendable @escaping (Result) -> Void) { 160 | var request = URLRequest(url: tokenApiURL) 161 | request.addValue("Bearer \(token.accessToken)", forHTTPHeaderField: "Authorization") 162 | request.httpMethod = "DELETE" 163 | 164 | URLSession.shared.dataTask(with: request) { data, response, sessionError in 165 | guard let response = response as? HTTPURLResponse, let data else { 166 | completion(.failure(sessionError ?? InfomaniakLoginError.unknownNetworkError)) 167 | return 168 | } 169 | 170 | do { 171 | if !response.isSuccessful() { 172 | let apiDeleteToken = try JSONDecoder().decode(ApiDeleteToken.self, from: data) 173 | completion(.failure(NSError( 174 | domain: apiDeleteToken.error!, 175 | code: response.statusCode, 176 | userInfo: ["Error": apiDeleteToken.error!] 177 | ))) 178 | } else { 179 | completion(.success(())) 180 | } 181 | } catch { 182 | completion(.failure(error)) 183 | } 184 | }.resume() 185 | } 186 | 187 | // MARK: Private 188 | 189 | /// Make the get token network call 190 | private func getApiToken(request: URLRequest, completion: @Sendable @escaping (Result) -> Void) { 191 | let session = URLSession.shared 192 | session.dataTask(with: request) { data, response, sessionError in 193 | guard let response = response as? HTTPURLResponse, 194 | let data = data, data.count > 0 else { 195 | completion(.failure(sessionError ?? InfomaniakLoginError.unknownNetworkError)) 196 | return 197 | } 198 | 199 | do { 200 | if response.isSuccessful() { 201 | let apiToken = try JSONDecoder().decode(ApiToken.self, from: data) 202 | completion(.success(apiToken)) 203 | } else { 204 | let apiError = try JSONDecoder().decode(LoginApiError.self, from: data) 205 | completion(.failure(NSError( 206 | domain: apiError.error, 207 | code: response.statusCode, 208 | userInfo: ["Error": apiError] 209 | ))) 210 | } 211 | } catch { 212 | completion(.failure(error)) 213 | } 214 | }.resume() 215 | } 216 | } 217 | 218 | extension HTTPURLResponse { 219 | func isSuccessful() -> Bool { 220 | return statusCode >= 200 && statusCode <= 299 221 | } 222 | } 223 | 224 | extension Dictionary { 225 | func percentEncoded() -> Data? { 226 | return map { key, value in 227 | let escapedKey = "\(key)".addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? "" 228 | let escapedValue = "\(value)".addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? "" 229 | return escapedKey + "=" + escapedValue 230 | } 231 | .joined(separator: "&") 232 | .data(using: .utf8) 233 | } 234 | } 235 | 236 | extension CharacterSet { 237 | static let urlQueryValueAllowed: CharacterSet = { 238 | let generalDelimitersToEncode = ":#[]@" // does not include "?" or "/" due to RFC 3986 - Section 3.4 239 | let subDelimitersToEncode = "!$&'()*+,;=" 240 | 241 | var allowed = CharacterSet.urlQueryAllowed 242 | allowed.remove(charactersIn: "\(generalDelimitersToEncode)\(subDelimitersToEncode)") 243 | return allowed 244 | }() 245 | } 246 | -------------------------------------------------------------------------------- /login-Example/login-Example/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 35 | 47 | 59 | 71 | 83 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /login-Example/login-Example/ViewController.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 Infomaniak Network SA 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import AuthenticationServices 18 | import InfomaniakDeviceCheck 19 | @testable import InfomaniakDI 20 | import InfomaniakLogin 21 | import UIKit 22 | 23 | class ViewController: UIViewController, InfomaniakLoginDelegate, DeleteAccountDelegate { 24 | @LazyInjectService var loginService: InfomaniakLoginable 25 | @LazyInjectService var tokenService: InfomaniakNetworkLoginable 26 | 27 | func didCompleteDeleteAccount() { 28 | showAlert(title: "Account deleted", message: nil) 29 | } 30 | 31 | func didFailDeleteAccount(error: InfomaniakLoginError) { 32 | showAlert(title: "Delete Account Failed", message: error.localizedDescription) 33 | } 34 | 35 | func didFailLoginWith(error: Error) { 36 | showAlert(title: "Login Failed", message: error.localizedDescription) 37 | } 38 | 39 | func didCompleteLoginWith(code: String, verifier: String) { 40 | tokenService.getApiTokenUsing(code: code, codeVerifier: verifier) { result in 41 | var title: String? 42 | var description: String? 43 | 44 | switch result { 45 | case .success(let token): 46 | title = "Login completed" 47 | description = "UserId: \(token.userId)\nToken: \(token.accessToken)" 48 | case .failure(let error): 49 | title = "Login error" 50 | description = error.localizedDescription 51 | } 52 | 53 | guard let title, let description else { return } 54 | 55 | Task { @MainActor in 56 | self.showAlert(title: title, message: description) 57 | } 58 | } 59 | } 60 | 61 | @IBAction func deleteAccount(_ sender: Any) { 62 | loginService.asWebAuthenticationLoginFrom(anchor: ASPresentationAnchor(), 63 | useEphemeralSession: true, 64 | hideCreateAccountButton: true) { result in 65 | switch result { 66 | case .success((let code, let verifier)): 67 | self.tokenService.getApiTokenUsing(code: code, codeVerifier: verifier) { apiTokenResult in 68 | switch apiTokenResult { 69 | case .success(let token): 70 | Task { @MainActor in 71 | let deleteAccountViewController = DeleteAccountViewController.instantiateInViewController( 72 | delegate: self, 73 | accessToken: token.accessToken 74 | ) 75 | self.present(deleteAccountViewController, animated: true) 76 | } 77 | case .failure: 78 | break 79 | } 80 | } 81 | case .failure: 82 | break 83 | } 84 | } 85 | } 86 | 87 | @IBAction func login(_ sender: UIButton) { 88 | loginService.loginFrom(viewController: self, 89 | hideCreateAccountButton: true, 90 | delegate: self) 91 | } 92 | 93 | @IBAction func webviewLogin(_ sender: UIButton) { 94 | loginService.setupWebviewNavbar(title: nil, 95 | titleColor: nil, 96 | color: nil, 97 | buttonColor: UIColor.white, 98 | clearCookie: true, 99 | timeOutMessage: "Problème de chargement !") 100 | loginService.webviewLoginFrom(viewController: self, 101 | hideCreateAccountButton: false, 102 | delegate: self) 103 | } 104 | 105 | @IBAction func asWebAuthentication(_ sender: Any) { 106 | loginService.asWebAuthenticationLoginFrom(anchor: ASPresentationAnchor(), 107 | useEphemeralSession: false, 108 | hideCreateAccountButton: true, 109 | delegate: self) 110 | } 111 | 112 | func showAlert(title: String, message: String?) { 113 | let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) 114 | alertController.addAction(UIAlertAction(title: "OK", style: .default) { _ in 115 | self.dismiss(animated: true, completion: nil) 116 | }) 117 | Task { 118 | try await Task.sleep(nanoseconds: 1_000_000_000) 119 | self.present(alertController, animated: true, completion: nil) 120 | } 121 | } 122 | 123 | @IBAction func refreshTokenConvert(_ sender: Any) { 124 | SimpleResolver.sharedResolver.removeAll() 125 | 126 | let clientId = "9473D73C-C20F-4971-9E10-D957C563FA68" 127 | let redirectUri = "com.infomaniak.drive://oauth2redirect" 128 | let config = InfomaniakLogin.Config( 129 | clientId: clientId, 130 | loginURL: AppDelegate.loginBaseURL, 131 | redirectURI: redirectUri, 132 | accessType: .offline 133 | ) 134 | // Init with non infinite refresh token 135 | SimpleResolver.sharedResolver.store(factory: Factory(type: InfomaniakLoginable.self) { _, _ in 136 | return InfomaniakLogin(config: config) 137 | }) 138 | SimpleResolver.sharedResolver.store(factory: Factory(type: InfomaniakNetworkLoginable.self) { _, resolver in 139 | return InfomaniakNetworkLogin(config: config) 140 | }) 141 | 142 | @InjectService var loginService: InfomaniakLoginable 143 | @InjectService var tokenService: InfomaniakNetworkLoginable 144 | 145 | loginService.asWebAuthenticationLoginFrom(anchor: .init(), 146 | useEphemeralSession: false, 147 | hideCreateAccountButton: true) { result in 148 | switch result { 149 | case .success(let success): 150 | self.tokenService.getApiTokenUsing(code: success.code, codeVerifier: success.verifier) { apiTokenResult in 151 | var title: String? 152 | var description: String? 153 | 154 | switch apiTokenResult { 155 | case .success(let token): 156 | title = "Login completed" 157 | description = 158 | "UserId: \(token.userId)\nToken: \(token.accessToken)\nExpires in: \(token.expiresIn ?? -1)" 159 | self.testSwapRefreshToken(apiToken: token) 160 | case .failure(let error): 161 | title = "Login error" 162 | description = error.localizedDescription 163 | } 164 | 165 | print("refreshTokenConvert \(title ?? "")\n\(description ?? "")") 166 | } 167 | case .failure(let failure): 168 | Task { @MainActor in 169 | self.showAlert(title: "Error", message: failure.localizedDescription) 170 | } 171 | } 172 | } 173 | } 174 | 175 | nonisolated func testSwapRefreshToken(apiToken: ApiToken) { 176 | SimpleResolver.sharedResolver.removeAll() 177 | 178 | let clientId = "9473D73C-C20F-4971-9E10-D957C563FA68" 179 | let redirectUri = "com.infomaniak.drive://oauth2redirect" 180 | let config = InfomaniakLogin.Config(clientId: clientId, 181 | loginURL: AppDelegate.loginBaseURL, 182 | redirectURI: redirectUri, 183 | accessType: .none) 184 | 185 | // Init with infinite refresh token 186 | SimpleResolver.sharedResolver.store(factory: Factory(type: InfomaniakLoginable.self) { _, _ in 187 | return InfomaniakLogin(config: config) 188 | }) 189 | SimpleResolver.sharedResolver.store(factory: Factory(type: InfomaniakNetworkLoginable.self) { _, resolver in 190 | return InfomaniakNetworkLogin(config: config) 191 | }) 192 | 193 | @InjectService var tokenService: InfomaniakNetworkLoginable 194 | 195 | tokenService.refreshToken(token: apiToken) { result in 196 | var title: String? 197 | var description: String? 198 | 199 | switch result { 200 | case .success(let token): 201 | title = "Login completed" 202 | description = "UserId: \(token.userId)\nToken: \(token.accessToken)\nExpires in: \(token.expiresIn ?? -1)" 203 | case .failure(let error): 204 | title = "Login error" 205 | description = error.localizedDescription 206 | } 207 | 208 | guard let title, let description else { return } 209 | 210 | Task { @MainActor in 211 | self.showAlert(title: title, message: description) 212 | } 213 | } 214 | } 215 | 216 | @IBAction func deriveToken(_ sender: Any) { 217 | Task { 218 | do { 219 | let (code, verifier) = try await asWebAuthenticationLogin() 220 | let driveApiToken = try await tokenService.apiTokenUsing(code: code, codeVerifier: verifier) 221 | 222 | let mailApiToken = try await derivateTokenAsMailClient(apiToken: driveApiToken) 223 | 224 | showAlert( 225 | title: "Derived token from kDrive to Mail", 226 | message: "Drive: \(driveApiToken.accessToken)\nMail: \(mailApiToken.accessToken)" 227 | ) 228 | } catch { 229 | showAlert(title: "Derivation Error", message: error.localizedDescription) 230 | } 231 | } 232 | } 233 | 234 | func derivateTokenAsMailClient(apiToken: ApiToken) async throws -> ApiToken { 235 | // reconfigure DI as if we were Mail instead of kDrive 236 | SimpleResolver.sharedResolver.removeAll() 237 | 238 | let clientId = "E90BC22D-67A8-452C-BE93-28DA33588CA4" 239 | let redirectUri = "com.infomaniak.mail://oauth2redirect" 240 | let config = InfomaniakLogin.Config( 241 | clientId: clientId, 242 | loginURL: AppDelegate.loginBaseURL, 243 | redirectURI: redirectUri, 244 | accessType: .none 245 | ) 246 | 247 | let protectedLoginURL = config.loginURL.appendingPathComponent("token") 248 | let attestationToken = try await InfomaniakDeviceCheck(environment: .preprod) 249 | .generateAttestationFor( 250 | targetUrl: protectedLoginURL, 251 | bundleId: "com.infomaniak.mail", 252 | bypassValidation: true 253 | ) 254 | 255 | SimpleResolver.sharedResolver.store(factory: Factory(type: InfomaniakLoginable.self) { _, _ in 256 | return InfomaniakLogin(config: config) 257 | }) 258 | SimpleResolver.sharedResolver.store(factory: Factory(type: InfomaniakNetworkLoginable.self) { _, resolver in 259 | return InfomaniakNetworkLogin(config: config) 260 | }) 261 | 262 | @InjectService var tokenService: InfomaniakNetworkLoginable 263 | 264 | let derivatedToken = try await tokenService.derivateApiToken(using: apiToken, attestationToken: attestationToken) 265 | return derivatedToken 266 | } 267 | 268 | func asWebAuthenticationLogin() async throws -> (code: String, verifier: String) { 269 | return try await withCheckedThrowingContinuation { continuation in 270 | loginService.asWebAuthenticationLoginFrom( 271 | anchor: .init(), 272 | useEphemeralSession: true, 273 | hideCreateAccountButton: true 274 | ) { 275 | continuation.resume(with: $0) 276 | } 277 | } 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /login-Example/login-Example.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 54; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 379A706E2DDCAAC800F276E4 /* InfomaniakDeviceCheck in Frameworks */ = {isa = PBXBuildFile; productRef = 379A706D2DDCAAC800F276E4 /* InfomaniakDeviceCheck */; }; 11 | C711BF41247E914E0018D5BF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C711BF40247E914E0018D5BF /* AppDelegate.swift */; }; 12 | C711BF43247E914E0018D5BF /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C711BF42247E914E0018D5BF /* SceneDelegate.swift */; }; 13 | C711BF45247E914E0018D5BF /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C711BF44247E914E0018D5BF /* ViewController.swift */; }; 14 | C711BF48247E914E0018D5BF /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C711BF46247E914E0018D5BF /* Main.storyboard */; }; 15 | C711BF4A247E91540018D5BF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C711BF49247E91540018D5BF /* Assets.xcassets */; }; 16 | C711BF4D247E91540018D5BF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C711BF4B247E91540018D5BF /* LaunchScreen.storyboard */; }; 17 | C72EF9B7247FB50100F53C68 /* InfomaniakLogin in Frameworks */ = {isa = PBXBuildFile; productRef = C72EF9B6247FB50100F53C68 /* InfomaniakLogin */; }; 18 | /* End PBXBuildFile section */ 19 | 20 | /* Begin PBXFileReference section */ 21 | C711BF3D247E914E0018D5BF /* login-Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "login-Example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 22 | C711BF40247E914E0018D5BF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 23 | C711BF42247E914E0018D5BF /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 24 | C711BF44247E914E0018D5BF /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 25 | C711BF47247E914E0018D5BF /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 26 | C711BF49247E91540018D5BF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 27 | C711BF4C247E91540018D5BF /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 28 | C711BF4E247E91540018D5BF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 29 | C72EF9B5247FB4F500F53C68 /* login */ = {isa = PBXFileReference; lastKnownFileType = folder; name = login; path = ..; sourceTree = ""; }; 30 | /* End PBXFileReference section */ 31 | 32 | /* Begin PBXFrameworksBuildPhase section */ 33 | C711BF3A247E914E0018D5BF /* Frameworks */ = { 34 | isa = PBXFrameworksBuildPhase; 35 | buildActionMask = 2147483647; 36 | files = ( 37 | 379A706E2DDCAAC800F276E4 /* InfomaniakDeviceCheck in Frameworks */, 38 | C72EF9B7247FB50100F53C68 /* InfomaniakLogin in Frameworks */, 39 | ); 40 | runOnlyForDeploymentPostprocessing = 0; 41 | }; 42 | /* End PBXFrameworksBuildPhase section */ 43 | 44 | /* Begin PBXGroup section */ 45 | C711BF34247E914E0018D5BF = { 46 | isa = PBXGroup; 47 | children = ( 48 | C711BF3F247E914E0018D5BF /* login-Example */, 49 | C711BF3E247E914E0018D5BF /* Products */, 50 | C72EF9A9247FB04D00F53C68 /* Frameworks */, 51 | ); 52 | sourceTree = ""; 53 | }; 54 | C711BF3E247E914E0018D5BF /* Products */ = { 55 | isa = PBXGroup; 56 | children = ( 57 | C711BF3D247E914E0018D5BF /* login-Example.app */, 58 | ); 59 | name = Products; 60 | sourceTree = ""; 61 | }; 62 | C711BF3F247E914E0018D5BF /* login-Example */ = { 63 | isa = PBXGroup; 64 | children = ( 65 | C711BF40247E914E0018D5BF /* AppDelegate.swift */, 66 | C711BF42247E914E0018D5BF /* SceneDelegate.swift */, 67 | C711BF44247E914E0018D5BF /* ViewController.swift */, 68 | C711BF46247E914E0018D5BF /* Main.storyboard */, 69 | C711BF49247E91540018D5BF /* Assets.xcassets */, 70 | C711BF4B247E91540018D5BF /* LaunchScreen.storyboard */, 71 | C711BF4E247E91540018D5BF /* Info.plist */, 72 | ); 73 | path = "login-Example"; 74 | sourceTree = ""; 75 | }; 76 | C72EF9A9247FB04D00F53C68 /* Frameworks */ = { 77 | isa = PBXGroup; 78 | children = ( 79 | C72EF9B5247FB4F500F53C68 /* login */, 80 | ); 81 | name = Frameworks; 82 | sourceTree = ""; 83 | }; 84 | /* End PBXGroup section */ 85 | 86 | /* Begin PBXNativeTarget section */ 87 | C711BF3C247E914E0018D5BF /* login-Example */ = { 88 | isa = PBXNativeTarget; 89 | buildConfigurationList = C711BF51247E91540018D5BF /* Build configuration list for PBXNativeTarget "login-Example" */; 90 | buildPhases = ( 91 | C711BF39247E914E0018D5BF /* Sources */, 92 | C711BF3A247E914E0018D5BF /* Frameworks */, 93 | C711BF3B247E914E0018D5BF /* Resources */, 94 | ); 95 | buildRules = ( 96 | ); 97 | dependencies = ( 98 | ); 99 | name = "login-Example"; 100 | packageProductDependencies = ( 101 | C72EF9B6247FB50100F53C68 /* InfomaniakLogin */, 102 | 379A706D2DDCAAC800F276E4 /* InfomaniakDeviceCheck */, 103 | ); 104 | productName = "login-Example"; 105 | productReference = C711BF3D247E914E0018D5BF /* login-Example.app */; 106 | productType = "com.apple.product-type.application"; 107 | }; 108 | /* End PBXNativeTarget section */ 109 | 110 | /* Begin PBXProject section */ 111 | C711BF35247E914E0018D5BF /* Project object */ = { 112 | isa = PBXProject; 113 | attributes = { 114 | LastSwiftUpdateCheck = 1150; 115 | LastUpgradeCheck = 1150; 116 | ORGANIZATIONNAME = infomaniak; 117 | TargetAttributes = { 118 | C711BF3C247E914E0018D5BF = { 119 | CreatedOnToolsVersion = 11.5; 120 | }; 121 | }; 122 | }; 123 | buildConfigurationList = C711BF38247E914E0018D5BF /* Build configuration list for PBXProject "login-Example" */; 124 | compatibilityVersion = "Xcode 9.3"; 125 | developmentRegion = en; 126 | hasScannedForEncodings = 0; 127 | knownRegions = ( 128 | en, 129 | Base, 130 | ); 131 | mainGroup = C711BF34247E914E0018D5BF; 132 | packageReferences = ( 133 | 379A706C2DDCAAC800F276E4 /* XCRemoteSwiftPackageReference "ios-device-check" */, 134 | ); 135 | productRefGroup = C711BF3E247E914E0018D5BF /* Products */; 136 | projectDirPath = ""; 137 | projectRoot = ""; 138 | targets = ( 139 | C711BF3C247E914E0018D5BF /* login-Example */, 140 | ); 141 | }; 142 | /* End PBXProject section */ 143 | 144 | /* Begin PBXResourcesBuildPhase section */ 145 | C711BF3B247E914E0018D5BF /* Resources */ = { 146 | isa = PBXResourcesBuildPhase; 147 | buildActionMask = 2147483647; 148 | files = ( 149 | C711BF4D247E91540018D5BF /* LaunchScreen.storyboard in Resources */, 150 | C711BF4A247E91540018D5BF /* Assets.xcassets in Resources */, 151 | C711BF48247E914E0018D5BF /* Main.storyboard in Resources */, 152 | ); 153 | runOnlyForDeploymentPostprocessing = 0; 154 | }; 155 | /* End PBXResourcesBuildPhase section */ 156 | 157 | /* Begin PBXSourcesBuildPhase section */ 158 | C711BF39247E914E0018D5BF /* Sources */ = { 159 | isa = PBXSourcesBuildPhase; 160 | buildActionMask = 2147483647; 161 | files = ( 162 | C711BF45247E914E0018D5BF /* ViewController.swift in Sources */, 163 | C711BF41247E914E0018D5BF /* AppDelegate.swift in Sources */, 164 | C711BF43247E914E0018D5BF /* SceneDelegate.swift in Sources */, 165 | ); 166 | runOnlyForDeploymentPostprocessing = 0; 167 | }; 168 | /* End PBXSourcesBuildPhase section */ 169 | 170 | /* Begin PBXVariantGroup section */ 171 | C711BF46247E914E0018D5BF /* Main.storyboard */ = { 172 | isa = PBXVariantGroup; 173 | children = ( 174 | C711BF47247E914E0018D5BF /* Base */, 175 | ); 176 | name = Main.storyboard; 177 | sourceTree = ""; 178 | }; 179 | C711BF4B247E91540018D5BF /* LaunchScreen.storyboard */ = { 180 | isa = PBXVariantGroup; 181 | children = ( 182 | C711BF4C247E91540018D5BF /* Base */, 183 | ); 184 | name = LaunchScreen.storyboard; 185 | sourceTree = ""; 186 | }; 187 | /* End PBXVariantGroup section */ 188 | 189 | /* Begin XCBuildConfiguration section */ 190 | C711BF4F247E91540018D5BF /* Debug */ = { 191 | isa = XCBuildConfiguration; 192 | buildSettings = { 193 | ALWAYS_SEARCH_USER_PATHS = NO; 194 | CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; 195 | CLANG_ANALYZER_NONNULL = YES; 196 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 197 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 198 | CLANG_CXX_LIBRARY = "libc++"; 199 | CLANG_ENABLE_MODULES = YES; 200 | CLANG_ENABLE_OBJC_ARC = YES; 201 | CLANG_ENABLE_OBJC_WEAK = YES; 202 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 203 | CLANG_WARN_BOOL_CONVERSION = YES; 204 | CLANG_WARN_COMMA = YES; 205 | CLANG_WARN_CONSTANT_CONVERSION = YES; 206 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 207 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 208 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 209 | CLANG_WARN_EMPTY_BODY = YES; 210 | CLANG_WARN_ENUM_CONVERSION = YES; 211 | CLANG_WARN_INFINITE_RECURSION = YES; 212 | CLANG_WARN_INT_CONVERSION = YES; 213 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 214 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 215 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 216 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 217 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 218 | CLANG_WARN_STRICT_PROTOTYPES = YES; 219 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 220 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 221 | CLANG_WARN_UNREACHABLE_CODE = YES; 222 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 223 | COPY_PHASE_STRIP = NO; 224 | DEBUG_INFORMATION_FORMAT = dwarf; 225 | ENABLE_STRICT_OBJC_MSGSEND = YES; 226 | ENABLE_TESTABILITY = YES; 227 | GCC_C_LANGUAGE_STANDARD = gnu11; 228 | GCC_DYNAMIC_NO_PIC = NO; 229 | GCC_NO_COMMON_BLOCKS = YES; 230 | GCC_OPTIMIZATION_LEVEL = 0; 231 | GCC_PREPROCESSOR_DEFINITIONS = ( 232 | "DEBUG=1", 233 | "$(inherited)", 234 | ); 235 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 236 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 237 | GCC_WARN_UNDECLARED_SELECTOR = YES; 238 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 239 | GCC_WARN_UNUSED_FUNCTION = YES; 240 | GCC_WARN_UNUSED_VARIABLE = YES; 241 | IPHONEOS_DEPLOYMENT_TARGET = 13.5; 242 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 243 | MTL_FAST_MATH = YES; 244 | ONLY_ACTIVE_ARCH = YES; 245 | SDKROOT = iphoneos; 246 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 247 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 248 | }; 249 | name = Debug; 250 | }; 251 | C711BF50247E91540018D5BF /* Release */ = { 252 | isa = XCBuildConfiguration; 253 | buildSettings = { 254 | ALWAYS_SEARCH_USER_PATHS = NO; 255 | CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; 256 | CLANG_ANALYZER_NONNULL = YES; 257 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 258 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 259 | CLANG_CXX_LIBRARY = "libc++"; 260 | CLANG_ENABLE_MODULES = YES; 261 | CLANG_ENABLE_OBJC_ARC = YES; 262 | CLANG_ENABLE_OBJC_WEAK = YES; 263 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 264 | CLANG_WARN_BOOL_CONVERSION = YES; 265 | CLANG_WARN_COMMA = YES; 266 | CLANG_WARN_CONSTANT_CONVERSION = YES; 267 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 268 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 269 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 270 | CLANG_WARN_EMPTY_BODY = YES; 271 | CLANG_WARN_ENUM_CONVERSION = YES; 272 | CLANG_WARN_INFINITE_RECURSION = YES; 273 | CLANG_WARN_INT_CONVERSION = YES; 274 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 275 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 276 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 277 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 278 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 279 | CLANG_WARN_STRICT_PROTOTYPES = YES; 280 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 281 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 282 | CLANG_WARN_UNREACHABLE_CODE = YES; 283 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 284 | COPY_PHASE_STRIP = NO; 285 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 286 | ENABLE_NS_ASSERTIONS = NO; 287 | ENABLE_STRICT_OBJC_MSGSEND = YES; 288 | GCC_C_LANGUAGE_STANDARD = gnu11; 289 | GCC_NO_COMMON_BLOCKS = YES; 290 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 291 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 292 | GCC_WARN_UNDECLARED_SELECTOR = YES; 293 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 294 | GCC_WARN_UNUSED_FUNCTION = YES; 295 | GCC_WARN_UNUSED_VARIABLE = YES; 296 | IPHONEOS_DEPLOYMENT_TARGET = 13.5; 297 | MTL_ENABLE_DEBUG_INFO = NO; 298 | MTL_FAST_MATH = YES; 299 | SDKROOT = iphoneos; 300 | SWIFT_COMPILATION_MODE = wholemodule; 301 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 302 | VALIDATE_PRODUCT = YES; 303 | }; 304 | name = Release; 305 | }; 306 | C711BF52247E91540018D5BF /* Debug */ = { 307 | isa = XCBuildConfiguration; 308 | buildSettings = { 309 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 310 | CODE_SIGN_STYLE = Automatic; 311 | DEVELOPMENT_TEAM = 864VDCS2QY; 312 | INFOPLIST_FILE = "login-Example/Info.plist"; 313 | IPHONEOS_DEPLOYMENT_TARGET = 15.6; 314 | LD_RUNPATH_SEARCH_PATHS = ( 315 | "$(inherited)", 316 | "@executable_path/Frameworks", 317 | ); 318 | PRODUCT_BUNDLE_IDENTIFIER = "com.infomaniak.login-Example"; 319 | PRODUCT_NAME = "$(TARGET_NAME)"; 320 | SWIFT_VERSION = 5.0; 321 | TARGETED_DEVICE_FAMILY = "1,2"; 322 | }; 323 | name = Debug; 324 | }; 325 | C711BF53247E91540018D5BF /* Release */ = { 326 | isa = XCBuildConfiguration; 327 | buildSettings = { 328 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 329 | CODE_SIGN_STYLE = Automatic; 330 | DEVELOPMENT_TEAM = 864VDCS2QY; 331 | INFOPLIST_FILE = "login-Example/Info.plist"; 332 | IPHONEOS_DEPLOYMENT_TARGET = 15.6; 333 | LD_RUNPATH_SEARCH_PATHS = ( 334 | "$(inherited)", 335 | "@executable_path/Frameworks", 336 | ); 337 | PRODUCT_BUNDLE_IDENTIFIER = "com.infomaniak.login-Example"; 338 | PRODUCT_NAME = "$(TARGET_NAME)"; 339 | SWIFT_VERSION = 5.0; 340 | TARGETED_DEVICE_FAMILY = "1,2"; 341 | }; 342 | name = Release; 343 | }; 344 | /* End XCBuildConfiguration section */ 345 | 346 | /* Begin XCConfigurationList section */ 347 | C711BF38247E914E0018D5BF /* Build configuration list for PBXProject "login-Example" */ = { 348 | isa = XCConfigurationList; 349 | buildConfigurations = ( 350 | C711BF4F247E91540018D5BF /* Debug */, 351 | C711BF50247E91540018D5BF /* Release */, 352 | ); 353 | defaultConfigurationIsVisible = 0; 354 | defaultConfigurationName = Release; 355 | }; 356 | C711BF51247E91540018D5BF /* Build configuration list for PBXNativeTarget "login-Example" */ = { 357 | isa = XCConfigurationList; 358 | buildConfigurations = ( 359 | C711BF52247E91540018D5BF /* Debug */, 360 | C711BF53247E91540018D5BF /* Release */, 361 | ); 362 | defaultConfigurationIsVisible = 0; 363 | defaultConfigurationName = Release; 364 | }; 365 | /* End XCConfigurationList section */ 366 | 367 | /* Begin XCRemoteSwiftPackageReference section */ 368 | 379A706C2DDCAAC800F276E4 /* XCRemoteSwiftPackageReference "ios-device-check" */ = { 369 | isa = XCRemoteSwiftPackageReference; 370 | repositoryURL = "https://github.com/Infomaniak/ios-device-check"; 371 | requirement = { 372 | kind = upToNextMajorVersion; 373 | minimumVersion = 1.1.0; 374 | }; 375 | }; 376 | /* End XCRemoteSwiftPackageReference section */ 377 | 378 | /* Begin XCSwiftPackageProductDependency section */ 379 | 379A706D2DDCAAC800F276E4 /* InfomaniakDeviceCheck */ = { 380 | isa = XCSwiftPackageProductDependency; 381 | package = 379A706C2DDCAAC800F276E4 /* XCRemoteSwiftPackageReference "ios-device-check" */; 382 | productName = InfomaniakDeviceCheck; 383 | }; 384 | C72EF9B6247FB50100F53C68 /* InfomaniakLogin */ = { 385 | isa = XCSwiftPackageProductDependency; 386 | productName = InfomaniakLogin; 387 | }; 388 | /* End XCSwiftPackageProductDependency section */ 389 | }; 390 | rootObject = C711BF35247E914E0018D5BF /* Project object */; 391 | } 392 | -------------------------------------------------------------------------------- /Sources/InfomaniakLogin/InfomaniakLogin.swift: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 Infomaniak Network SA 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import AuthenticationServices 18 | import CommonCrypto 19 | import InfomaniakDI 20 | import SafariServices 21 | import WebKit 22 | #if canImport(UIKit) 23 | import UIKit 24 | #endif 25 | 26 | public enum Constants { 27 | public static let deleteAccountURL = 28 | "https://manager.infomaniak.com/v3/ng/profile/user/dashboard?open-terminate-account-modal" 29 | public static func autologinUrl(to destination: String) -> URL? { 30 | return URL(string: "https://manager.infomaniak.com/v3/mobile_login/?url=\(destination)") 31 | } 32 | } 33 | 34 | /// Login delegation 35 | public protocol InfomaniakLoginDelegate: AnyObject { 36 | func didCompleteLoginWith(code: String, verifier: String) 37 | func didFailLoginWith(error: Error) 38 | } 39 | 40 | /// Something that can authentify with Infomaniak 41 | public protocol InfomaniakLoginable { 42 | var config: InfomaniakLogin.Config { get } 43 | 44 | @available(iOS 13.0, *) 45 | @MainActor 46 | func asWebAuthenticationLoginFrom( 47 | anchor: ASPresentationAnchor, 48 | useEphemeralSession: Bool, 49 | hideCreateAccountButton: Bool 50 | ) async throws -> (code: String, verifier: String) 51 | 52 | @available(iOS 13.0, *) 53 | @MainActor 54 | func asWebAuthenticationLoginFrom(anchor: ASPresentationAnchor, 55 | useEphemeralSession: Bool, 56 | hideCreateAccountButton: Bool, 57 | completion: @escaping (Result<(code: String, verifier: String), Error>) -> Void) 58 | @available(iOS 13.0, *) 59 | @MainActor 60 | func asWebAuthenticationLoginFrom(anchor: ASPresentationAnchor, 61 | useEphemeralSession: Bool, 62 | hideCreateAccountButton: Bool, 63 | delegate: InfomaniakLoginDelegate?) 64 | 65 | #if canImport(UIKit) 66 | @MainActor 67 | func handleRedirectUri(url: URL) -> Bool 68 | 69 | @MainActor 70 | func loginFrom(viewController: UIViewController, 71 | hideCreateAccountButton: Bool, 72 | delegate: InfomaniakLoginDelegate?) 73 | 74 | @MainActor 75 | func webviewLoginFrom(viewController: UIViewController, 76 | hideCreateAccountButton: Bool, 77 | delegate: InfomaniakLoginDelegate?) 78 | 79 | @MainActor 80 | func setupWebviewNavbar(title: String?, 81 | titleColor: UIColor?, 82 | color: UIColor?, 83 | buttonColor: UIColor?, 84 | clearCookie: Bool, 85 | timeOutMessage: String?) 86 | 87 | @MainActor 88 | func webviewHandleRedirectUri(url: URL) -> Bool 89 | #endif 90 | } 91 | 92 | @MainActor 93 | class PresentationContext: NSObject, ASWebAuthenticationPresentationContextProviding { 94 | private let anchor: ASPresentationAnchor 95 | init(anchor: ASPresentationAnchor) { 96 | self.anchor = anchor 97 | } 98 | 99 | func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { 100 | return anchor 101 | } 102 | } 103 | 104 | public class InfomaniakLogin: InfomaniakLoginable { 105 | let networkLogin: InfomaniakNetworkLoginable 106 | 107 | public let config: Config 108 | 109 | private var delegate: InfomaniakLoginDelegate? 110 | 111 | private var codeChallenge: String! 112 | private var codeChallengeMethod: String! 113 | private var codeVerifier: String! 114 | 115 | private var asPresentationContext: PresentationContext? 116 | private var hideCreateAccountButton = true 117 | 118 | #if canImport(UIKit) 119 | private var safariViewController: SFSafariViewController? 120 | 121 | private var clearCookie = false 122 | private var webViewController: WebViewController? 123 | private var webviewNavbarButtonColor: UIColor? 124 | private var webviewNavbarColor: UIColor? 125 | private var webviewNavbarTitle: String? 126 | private var webviewNavbarTitleColor: UIColor? 127 | private var webviewTimeOutMessage: String? 128 | #endif 129 | 130 | public init(config: Config) { 131 | self.config = config 132 | networkLogin = InfomaniakNetworkLogin(config: config) 133 | } 134 | 135 | @available(iOS 13.0, *) 136 | @MainActor 137 | public func asWebAuthenticationLoginFrom( 138 | anchor: ASPresentationAnchor, 139 | useEphemeralSession: Bool, 140 | hideCreateAccountButton: Bool 141 | ) async throws -> (code: String, verifier: String) { 142 | try await withCheckedThrowingContinuation { continuation in 143 | asWebAuthenticationLoginFrom( 144 | anchor: anchor, 145 | useEphemeralSession: useEphemeralSession, 146 | hideCreateAccountButton: hideCreateAccountButton 147 | ) { result in 148 | continuation.resume(with: result) 149 | } 150 | } 151 | } 152 | 153 | @available(iOS 13.0, *) 154 | @MainActor 155 | public func asWebAuthenticationLoginFrom(anchor: ASPresentationAnchor = ASPresentationAnchor(), 156 | useEphemeralSession: Bool = false, 157 | hideCreateAccountButton: Bool = true, 158 | completion: @escaping (Result<(code: String, verifier: String), Error>) -> Void) { 159 | self.hideCreateAccountButton = hideCreateAccountButton 160 | generatePkceCodes() 161 | 162 | guard let loginUrl = generateUrl(), 163 | let callbackUrl = URL(string: config.redirectURI), 164 | let callbackUrlScheme = callbackUrl.scheme else { 165 | return 166 | } 167 | 168 | let session = ASWebAuthenticationSession(url: loginUrl, callbackURLScheme: callbackUrlScheme) { callbackURL, error in 169 | if let callbackURL = callbackURL { 170 | _ = InfomaniakLogin.checkResponse(url: callbackURL, 171 | onSuccess: { code in 172 | completion(.success((code: code, verifier: self.codeVerifier))) 173 | }, 174 | onFailure: { error in 175 | completion(.failure(error)) 176 | }) 177 | } else if let error = error { 178 | completion(.failure(error)) 179 | } 180 | } 181 | asPresentationContext = PresentationContext(anchor: anchor) 182 | session.presentationContextProvider = asPresentationContext 183 | session.prefersEphemeralWebBrowserSession = useEphemeralSession 184 | session.start() 185 | } 186 | 187 | @available(iOS 13.0, *) 188 | public func asWebAuthenticationLoginFrom(anchor: ASPresentationAnchor = ASPresentationAnchor(), 189 | useEphemeralSession: Bool = false, 190 | hideCreateAccountButton: Bool = true, 191 | delegate: InfomaniakLoginDelegate? = nil) { 192 | self.delegate = delegate 193 | asWebAuthenticationLoginFrom(anchor: anchor, useEphemeralSession: useEphemeralSession, 194 | hideCreateAccountButton: hideCreateAccountButton) { result in 195 | switch result { 196 | case .success(let result): 197 | delegate?.didCompleteLoginWith(code: result.code, verifier: result.verifier) 198 | case .failure(let error): 199 | delegate?.didFailLoginWith(error: error) 200 | } 201 | } 202 | } 203 | 204 | // MARK: - Internal 205 | 206 | static func checkResponse(url: URL, onSuccess: (String) -> Void, onFailure: (InfomaniakLoginError) -> Void) -> Bool { 207 | if let code = URLComponents(string: url.absoluteString)?.queryItems?.first(where: { $0.name == "code" })?.value { 208 | onSuccess(code) 209 | return true 210 | } else { 211 | onFailure(.accessDenied) 212 | return false 213 | } 214 | } 215 | 216 | // MARK: - Private 217 | 218 | private func generatePkceCodes() { 219 | codeChallengeMethod = config.hashModeShort 220 | codeVerifier = generateCodeVerifier() 221 | codeChallenge = generateCodeChallenge(codeVerifier: codeVerifier) 222 | } 223 | 224 | /// Generate the complete login URL based on parameters and base 225 | private func generateUrl() -> URL? { 226 | var urlComponents = URLComponents(url: config.loginURL, resolvingAgainstBaseURL: true) 227 | urlComponents?.path = "/authorize" 228 | urlComponents?.queryItems = [ 229 | URLQueryItem(name: "response_type", value: config.responseType.rawValue), 230 | URLQueryItem(name: "client_id", value: config.clientId), 231 | URLQueryItem(name: "redirect_uri", value: config.redirectURI), 232 | URLQueryItem(name: "code_challenge_method", value: codeChallengeMethod), 233 | URLQueryItem(name: "code_challenge", value: codeChallenge) 234 | ] 235 | 236 | if let accessType = config.accessType?.rawValue { 237 | urlComponents?.queryItems?.append(URLQueryItem(name: "access_type", value: accessType)) 238 | } 239 | 240 | if hideCreateAccountButton { 241 | urlComponents?.queryItems?.append(URLQueryItem(name: "hide_create_account", value: "")) 242 | } 243 | return urlComponents?.url 244 | } 245 | 246 | /// Generate a verifier code for PKCE challenge (rfc7636 4.1.) 247 | /// 248 | /// https://auth0.com/docs/api-auth/tutorials/authorization-code-grant-pkce 249 | private func generateCodeVerifier() -> String { 250 | var buffer = [UInt8](repeating: 0, count: 32) 251 | _ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer) 252 | return Data(buffer).base64EncodedString() 253 | .replacingOccurrences(of: "+", with: "-") 254 | .replacingOccurrences(of: "/", with: "_") 255 | .replacingOccurrences(of: "=", with: "") 256 | .trimmingCharacters(in: .whitespaces) 257 | } 258 | 259 | /// Generate a challenge code for PKCE challenge (rfc7636 4.2.) 260 | /// 261 | /// https://auth0.com/docs/api-auth/tutorials/authorization-code-grant-pkce 262 | private func generateCodeChallenge(codeVerifier: String) -> String { 263 | guard let data = codeVerifier.data(using: .utf8) else { 264 | return "" 265 | } 266 | var buffer = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) 267 | 268 | data.withUnsafeBytes { 269 | _ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &buffer) 270 | } 271 | 272 | return Data(buffer).base64EncodedString() 273 | .replacingOccurrences(of: "+", with: "-") 274 | .replacingOccurrences(of: "/", with: "_") 275 | .replacingOccurrences(of: "=", with: "") 276 | .trimmingCharacters(in: .whitespaces) 277 | } 278 | } 279 | 280 | #if canImport(UIKit) 281 | public extension InfomaniakLogin { 282 | @MainActor 283 | func handleRedirectUri(url: URL) -> Bool { 284 | return InfomaniakLogin.checkResponse(url: url, 285 | onSuccess: { code in 286 | safariViewController?.dismiss(animated: true) { 287 | self.delegate?.didCompleteLoginWith(code: code, verifier: self.codeVerifier) 288 | } 289 | }, 290 | onFailure: { error in 291 | safariViewController?.dismiss(animated: true) { 292 | self.delegate?.didFailLoginWith(error: error) 293 | } 294 | }) 295 | } 296 | 297 | @MainActor 298 | func webviewHandleRedirectUri(url: URL) -> Bool { 299 | return InfomaniakLogin.checkResponse(url: url, 300 | onSuccess: { code in 301 | webViewController?.dismiss(animated: true) { 302 | self.delegate?.didCompleteLoginWith(code: code, verifier: self.codeVerifier) 303 | } 304 | }, 305 | onFailure: { error in 306 | webViewController?.dismiss(animated: true) { 307 | self.delegate?.didFailLoginWith(error: error) 308 | } 309 | }) 310 | } 311 | 312 | @MainActor 313 | func loginFrom(viewController: UIViewController, 314 | hideCreateAccountButton: Bool = true, 315 | delegate: InfomaniakLoginDelegate? = nil) { 316 | self.hideCreateAccountButton = hideCreateAccountButton 317 | self.delegate = delegate 318 | generatePkceCodes() 319 | 320 | guard let loginUrl = generateUrl() else { 321 | return 322 | } 323 | 324 | safariViewController = SFSafariViewController(url: loginUrl) 325 | viewController.present(safariViewController!, animated: true) 326 | } 327 | 328 | @MainActor 329 | func webviewLoginFrom(viewController: UIViewController, 330 | hideCreateAccountButton: Bool = true, 331 | delegate: InfomaniakLoginDelegate? = nil) { 332 | self.hideCreateAccountButton = hideCreateAccountButton 333 | self.delegate = delegate 334 | generatePkceCodes() 335 | 336 | guard let loginUrl = generateUrl() else { 337 | return 338 | } 339 | 340 | let urlRequest = URLRequest(url: loginUrl) 341 | let webViewController = WebViewController( 342 | clearCookie: clearCookie, 343 | redirectUri: config.redirectURI, 344 | urlRequest: urlRequest 345 | ) 346 | self.webViewController = webViewController 347 | 348 | if let navigationController = viewController as? UINavigationController { 349 | navigationController.pushViewController(webViewController, animated: true) 350 | } else { 351 | let navigationController = UINavigationController(rootViewController: webViewController) 352 | viewController.present(navigationController, animated: true) 353 | } 354 | 355 | webViewController.navBarTitle = webviewNavbarTitle 356 | webViewController.navBarTitleColor = webviewNavbarTitleColor 357 | webViewController.navBarColor = webviewNavbarColor 358 | webViewController.navBarButtonColor = webviewNavbarButtonColor 359 | webViewController.timeOutMessage = webviewTimeOutMessage 360 | } 361 | 362 | @MainActor 363 | func setupWebviewNavbar(title: String?, 364 | titleColor: UIColor?, 365 | color: UIColor?, 366 | buttonColor: UIColor?, 367 | clearCookie: Bool = false, 368 | timeOutMessage: String?) { 369 | webviewNavbarTitle = title 370 | webviewNavbarTitleColor = titleColor 371 | webviewNavbarColor = color 372 | webviewNavbarButtonColor = buttonColor 373 | self.clearCookie = clearCookie 374 | webviewTimeOutMessage = timeOutMessage 375 | } 376 | } 377 | #endif 378 | --------------------------------------------------------------------------------