capacitor-oidc 0.0.1-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CapacitorOidc.podspec +17 -0
- package/LICENSE +201 -0
- package/Package.swift +28 -0
- package/README.md +122 -0
- package/android/build.gradle +75 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/jojopirker/capacitor/oidc/CallbackUriMatcher.java +22 -0
- package/android/src/main/java/com/jojopirker/capacitor/oidc/CapacitorOidcPlugin.java +240 -0
- package/android/src/main/java/com/jojopirker/capacitor/oidc/NativeContract.java +35 -0
- package/android/src/main/java/com/jojopirker/capacitor/oidc/StoredSessionV1.java +111 -0
- package/android/src/main/java/com/jojopirker/capacitor/oidc/TokenVault.java +209 -0
- package/android/src/main/java/com/jojopirker/capacitor/oidc/VaultEntryCodec.java +56 -0
- package/dist/esm/capacitor-navigator.d.ts +14 -0
- package/dist/esm/capacitor-navigator.js +68 -0
- package/dist/esm/capacitor-navigator.js.map +1 -0
- package/dist/esm/capacitor-secure-state-store.d.ts +9 -0
- package/dist/esm/capacitor-secure-state-store.js +36 -0
- package/dist/esm/capacitor-secure-state-store.js.map +1 -0
- package/dist/esm/capacitor-user-manager.d.ts +28 -0
- package/dist/esm/capacitor-user-manager.js +160 -0
- package/dist/esm/capacitor-user-manager.js.map +1 -0
- package/dist/esm/definitions.d.ts +62 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/errors.d.ts +6 -0
- package/dist/esm/errors.js +13 -0
- package/dist/esm/errors.js.map +1 -0
- package/dist/esm/generated/native-contract.d.ts +37 -0
- package/dist/esm/generated/native-contract.js +38 -0
- package/dist/esm/generated/native-contract.js.map +1 -0
- package/dist/esm/index.d.ts +5 -0
- package/dist/esm/index.js +5 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/native.d.ts +2 -0
- package/dist/esm/native.js +4 -0
- package/dist/esm/native.js.map +1 -0
- package/dist/plugin.cjs +304 -0
- package/dist/plugin.cjs.map +1 -0
- package/ios/Sources/CapacitorOidcPlugin/CapacitorOidcPlugin.swift +177 -0
- package/ios/Sources/CapacitorOidcPlugin/NativeContract.generated.swift +39 -0
- package/ios/Sources/CapacitorOidcPlugin/StoredSessionV1.swift +35 -0
- package/ios/Sources/CapacitorOidcPlugin/TokenVault.swift +132 -0
- package/ios/Tests/CapacitorOidcPluginTests/StoredSessionV1Tests.swift +38 -0
- package/package.json +85 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Security
|
|
3
|
+
|
|
4
|
+
public enum TokenVaultAccessibility: Sendable {
|
|
5
|
+
case afterFirstUnlockThisDeviceOnly
|
|
6
|
+
case whenUnlockedThisDeviceOnly
|
|
7
|
+
|
|
8
|
+
var keychainValue: CFString {
|
|
9
|
+
switch self {
|
|
10
|
+
case .afterFirstUnlockThisDeviceOnly:
|
|
11
|
+
return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
12
|
+
case .whenUnlockedThisDeviceOnly:
|
|
13
|
+
return kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public enum TokenVaultError: Error {
|
|
19
|
+
case keychain(OSStatus)
|
|
20
|
+
case invalidSession
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public final class TokenVault: @unchecked Sendable {
|
|
24
|
+
public static let sessionSnapshotKey = "widget-session-v1"
|
|
25
|
+
|
|
26
|
+
private let service: String
|
|
27
|
+
private let accessGroup: String?
|
|
28
|
+
private let accessibility: TokenVaultAccessibility
|
|
29
|
+
|
|
30
|
+
public init(
|
|
31
|
+
service: String = "com.jojopirker.capacitor.oidc",
|
|
32
|
+
accessGroup: String? = nil,
|
|
33
|
+
accessibility: TokenVaultAccessibility = .afterFirstUnlockThisDeviceOnly
|
|
34
|
+
) {
|
|
35
|
+
self.service = service
|
|
36
|
+
self.accessGroup = accessGroup
|
|
37
|
+
self.accessibility = accessibility
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public func set(namespace: String, key: String, value: String) throws {
|
|
41
|
+
let data = Data(value.utf8)
|
|
42
|
+
let query = itemQuery(namespace: namespace, key: key)
|
|
43
|
+
let update: [CFString: Any] = [
|
|
44
|
+
kSecValueData: data,
|
|
45
|
+
kSecAttrAccessible: accessibility.keychainValue
|
|
46
|
+
]
|
|
47
|
+
let status = SecItemUpdate(query as CFDictionary, update as CFDictionary)
|
|
48
|
+
if status == errSecSuccess { return }
|
|
49
|
+
if status != errSecItemNotFound { throw TokenVaultError.keychain(status) }
|
|
50
|
+
|
|
51
|
+
var item = query
|
|
52
|
+
item[kSecValueData as String] = data
|
|
53
|
+
item[kSecAttrAccessible as String] = accessibility.keychainValue
|
|
54
|
+
let addStatus = SecItemAdd(item as CFDictionary, nil)
|
|
55
|
+
guard addStatus == errSecSuccess else { throw TokenVaultError.keychain(addStatus) }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public func get(namespace: String, key: String) throws -> String? {
|
|
59
|
+
var query = itemQuery(namespace: namespace, key: key)
|
|
60
|
+
query[kSecReturnData as String] = true
|
|
61
|
+
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
62
|
+
|
|
63
|
+
var result: CFTypeRef?
|
|
64
|
+
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
65
|
+
if status == errSecItemNotFound { return nil }
|
|
66
|
+
guard status == errSecSuccess, let data = result as? Data else {
|
|
67
|
+
throw TokenVaultError.keychain(status)
|
|
68
|
+
}
|
|
69
|
+
return String(data: data, encoding: .utf8)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@discardableResult
|
|
73
|
+
public func remove(namespace: String, key: String) throws -> String? {
|
|
74
|
+
let value = try get(namespace: namespace, key: key)
|
|
75
|
+
let status = SecItemDelete(itemQuery(namespace: namespace, key: key) as CFDictionary)
|
|
76
|
+
if status != errSecSuccess && status != errSecItemNotFound {
|
|
77
|
+
throw TokenVaultError.keychain(status)
|
|
78
|
+
}
|
|
79
|
+
return value
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
public func getAllKeys(namespace: String) throws -> [String] {
|
|
83
|
+
var query = baseQuery()
|
|
84
|
+
query[kSecReturnAttributes as String] = true
|
|
85
|
+
query[kSecMatchLimit as String] = kSecMatchLimitAll
|
|
86
|
+
|
|
87
|
+
var result: CFTypeRef?
|
|
88
|
+
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
89
|
+
if status == errSecItemNotFound { return [] }
|
|
90
|
+
guard status == errSecSuccess, let items = result as? [[String: Any]] else {
|
|
91
|
+
throw TokenVaultError.keychain(status)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let prefix = "\(namespace)."
|
|
95
|
+
return items.compactMap { item in
|
|
96
|
+
guard let account = item[kSecAttrAccount as String] as? String, account.hasPrefix(prefix) else { return nil }
|
|
97
|
+
return String(account.dropFirst(prefix.count))
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
public func setSessionSnapshot(namespace: String, value: String?) throws {
|
|
102
|
+
if let value {
|
|
103
|
+
try set(namespace: namespace, key: Self.sessionSnapshotKey, value: value)
|
|
104
|
+
} else {
|
|
105
|
+
try remove(namespace: namespace, key: Self.sessionSnapshotKey)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
public func loadSession(namespace: String) throws -> StoredSessionV1? {
|
|
110
|
+
guard let value = try get(namespace: namespace, key: Self.sessionSnapshotKey),
|
|
111
|
+
let data = value.data(using: .utf8) else { return nil }
|
|
112
|
+
let session = try JSONDecoder().decode(StoredSessionV1.self, from: data)
|
|
113
|
+
guard session.version == 1 else { throw TokenVaultError.invalidSession }
|
|
114
|
+
return session
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private func baseQuery() -> [String: Any] {
|
|
118
|
+
var query: [String: Any] = [
|
|
119
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
120
|
+
kSecAttrService as String: service,
|
|
121
|
+
kSecAttrSynchronizable as String: false
|
|
122
|
+
]
|
|
123
|
+
if let accessGroup { query[kSecAttrAccessGroup as String] = accessGroup }
|
|
124
|
+
return query
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private func itemQuery(namespace: String, key: String) -> [String: Any] {
|
|
128
|
+
var query = baseQuery()
|
|
129
|
+
query[kSecAttrAccount as String] = "\(namespace).\(key)"
|
|
130
|
+
return query
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import CapacitorOidcPlugin
|
|
3
|
+
|
|
4
|
+
final class StoredSessionV1Tests: XCTestCase {
|
|
5
|
+
func testDecodesSharedFixture() throws {
|
|
6
|
+
let fixtureURL = URL(fileURLWithPath: #filePath)
|
|
7
|
+
.deletingLastPathComponent()
|
|
8
|
+
.deletingLastPathComponent()
|
|
9
|
+
.deletingLastPathComponent()
|
|
10
|
+
.deletingLastPathComponent()
|
|
11
|
+
.appendingPathComponent("contracts/fixtures/stored-session-v1.json")
|
|
12
|
+
let session = try JSONDecoder().decode(StoredSessionV1.self, from: Data(contentsOf: fixtureURL))
|
|
13
|
+
|
|
14
|
+
XCTAssertEqual(session.version, 1)
|
|
15
|
+
XCTAssertEqual(session.issuer, "https://issuer.example")
|
|
16
|
+
XCTAssertEqual(session.clientId, "mobile")
|
|
17
|
+
XCTAssertEqual(session.accessToken, "access")
|
|
18
|
+
XCTAssertEqual(session.refreshToken, "refresh")
|
|
19
|
+
XCTAssertEqual(session.idToken, "id-token")
|
|
20
|
+
XCTAssertEqual(session.tokenType, "Bearer")
|
|
21
|
+
XCTAssertEqual(session.scope, "openid profile offline_access")
|
|
22
|
+
XCTAssertEqual(session.expiresAt, 1_800_000_000)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func testRoundTrip() throws {
|
|
26
|
+
let session = StoredSessionV1(
|
|
27
|
+
issuer: "https://issuer.example",
|
|
28
|
+
clientId: "mobile",
|
|
29
|
+
accessToken: "access",
|
|
30
|
+
refreshToken: "refresh",
|
|
31
|
+
tokenType: "Bearer",
|
|
32
|
+
expiresAt: 1_800_000_000
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
let decoded = try JSONDecoder().decode(StoredSessionV1.self, from: JSONEncoder().encode(session))
|
|
36
|
+
XCTAssertEqual(decoded, session)
|
|
37
|
+
}
|
|
38
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "capacitor-oidc",
|
|
3
|
+
"version": "0.0.1-alpha.0",
|
|
4
|
+
"description": "A minimal Capacitor adapter for standards-based OpenID Connect authentication.",
|
|
5
|
+
"main": "dist/plugin.cjs",
|
|
6
|
+
"module": "dist/esm/index.js",
|
|
7
|
+
"types": "dist/esm/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"android/src/main/",
|
|
10
|
+
"android/build.gradle",
|
|
11
|
+
"dist/",
|
|
12
|
+
"ios/Sources",
|
|
13
|
+
"ios/Tests",
|
|
14
|
+
"Package.swift",
|
|
15
|
+
"CapacitorOidc.podspec"
|
|
16
|
+
],
|
|
17
|
+
"license": "Apache-2.0",
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Jojo Pirker",
|
|
20
|
+
"email": "j.pirker@gmail.com"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/jojopirker/capacitor-oidc.git"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/jojopirker/capacitor-oidc/issues"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/jojopirker/capacitor-oidc#readme",
|
|
30
|
+
"type": "module",
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "npm run clean && tsc && rollup -c rollup.config.mjs",
|
|
33
|
+
"clean": "rimraf dist",
|
|
34
|
+
"check:contract": "node scripts/generate-native-contract.mjs --check",
|
|
35
|
+
"generate:contract": "node scripts/generate-native-contract.mjs",
|
|
36
|
+
"lint": "eslint src --ext ts && prettier --check src scripts contracts package.json README.md SECURITY.md docs",
|
|
37
|
+
"prepack": "npm run build",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"verify": "npm run check:contract && npm run lint && npm run test && npm run build",
|
|
40
|
+
"verify:ios": "xcodebuild -scheme CapacitorOidc -destination generic/platform=iOS CODE_SIGNING_ALLOWED=NO",
|
|
41
|
+
"verify:android": "cd android && ./gradlew clean test assembleDebug"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"oidc-client-ts": "3.5.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@capacitor/app": "^8.0.0",
|
|
48
|
+
"@capacitor/core": "^8.0.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@capacitor/android": "8.5.0",
|
|
52
|
+
"@capacitor/app": "8.1.1",
|
|
53
|
+
"@capacitor/core": "8.5.0",
|
|
54
|
+
"@capacitor/ios": "8.5.0",
|
|
55
|
+
"@ionic/eslint-config": "0.4.0",
|
|
56
|
+
"@ionic/prettier-config": "4.0.0",
|
|
57
|
+
"@types/node": "24.5.2",
|
|
58
|
+
"eslint": "8.57.1",
|
|
59
|
+
"prettier": "3.6.2",
|
|
60
|
+
"rimraf": "6.1.0",
|
|
61
|
+
"rollup": "4.62.5",
|
|
62
|
+
"typescript": "5.9.3",
|
|
63
|
+
"vitest": "3.2.7"
|
|
64
|
+
},
|
|
65
|
+
"prettier": "@ionic/prettier-config",
|
|
66
|
+
"eslintConfig": {
|
|
67
|
+
"extends": "@ionic/eslint-config/recommended",
|
|
68
|
+
"rules": {
|
|
69
|
+
"@typescript-eslint/no-unused-vars": [
|
|
70
|
+
"error",
|
|
71
|
+
{
|
|
72
|
+
"argsIgnorePattern": "^_"
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"capacitor": {
|
|
78
|
+
"ios": {
|
|
79
|
+
"src": "ios"
|
|
80
|
+
},
|
|
81
|
+
"android": {
|
|
82
|
+
"src": "android"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|