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,240 @@
|
|
|
1
|
+
package com.jojopirker.capacitor.oidc;
|
|
2
|
+
|
|
3
|
+
import android.content.ActivityNotFoundException;
|
|
4
|
+
import android.content.Intent;
|
|
5
|
+
import android.net.Uri;
|
|
6
|
+
import androidx.activity.result.ActivityResultLauncher;
|
|
7
|
+
import androidx.browser.auth.AuthTabIntent;
|
|
8
|
+
import com.getcapacitor.JSArray;
|
|
9
|
+
import com.getcapacitor.JSObject;
|
|
10
|
+
import com.getcapacitor.Plugin;
|
|
11
|
+
import com.getcapacitor.PluginCall;
|
|
12
|
+
import com.getcapacitor.PluginMethod;
|
|
13
|
+
import com.getcapacitor.annotation.CapacitorPlugin;
|
|
14
|
+
import java.io.IOException;
|
|
15
|
+
import java.security.GeneralSecurityException;
|
|
16
|
+
import java.util.List;
|
|
17
|
+
import org.json.JSONObject;
|
|
18
|
+
|
|
19
|
+
@CapacitorPlugin(name = NativeContract.PLUGIN_NAME)
|
|
20
|
+
public final class CapacitorOidcPlugin extends Plugin {
|
|
21
|
+
|
|
22
|
+
private ActivityResultLauncher<Intent> authLauncher;
|
|
23
|
+
private PluginCall pendingAuthCall;
|
|
24
|
+
private Uri pendingCallback;
|
|
25
|
+
private TokenVault vault;
|
|
26
|
+
|
|
27
|
+
@Override
|
|
28
|
+
public void load() {
|
|
29
|
+
vault = new TokenVault(getContext());
|
|
30
|
+
authLauncher = bridge.registerForActivityResult(new AuthTabIntent.AuthenticateUserResultContract(), this::handleAuthResult);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@PluginMethod
|
|
34
|
+
public void configure(PluginCall call) {
|
|
35
|
+
call.resolve();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@PluginMethod
|
|
39
|
+
public void open(PluginCall call) {
|
|
40
|
+
if (pendingAuthCall != null) {
|
|
41
|
+
call.reject("An authentication session is already active.", NativeContract.ERROR_AUTH_SESSION_IN_PROGRESS);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
String urlValue = requiredString(call, NativeContract.FIELD_URL);
|
|
46
|
+
String callbackValue = requiredString(call, NativeContract.FIELD_CALLBACK_URL);
|
|
47
|
+
if (urlValue == null || callbackValue == null) return;
|
|
48
|
+
|
|
49
|
+
Uri url = Uri.parse(urlValue);
|
|
50
|
+
Uri callback = Uri.parse(callbackValue);
|
|
51
|
+
if (!isSecureRequestUrl(url)) {
|
|
52
|
+
call.reject("The authentication URL must use HTTPS outside loopback development.", NativeContract.ERROR_BROWSER_UNAVAILABLE);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!isSupportedCallback(callback)) {
|
|
56
|
+
call.reject("The callback URL must use HTTPS or a custom scheme.", NativeContract.ERROR_INVALID_CALLBACK);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
boolean ephemeral = Boolean.TRUE.equals(call.getBoolean(NativeContract.FIELD_PREFERS_EPHEMERAL_WEB_BROWSER_SESSION, false));
|
|
61
|
+
AuthTabIntent authTab = new AuthTabIntent.Builder().setEphemeralBrowsingEnabled(ephemeral).build();
|
|
62
|
+
pendingAuthCall = call;
|
|
63
|
+
pendingCallback = callback;
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
if ("https".equalsIgnoreCase(callback.getScheme())) {
|
|
67
|
+
authTab.launch(authLauncher, url, callback.getHost(), path(callback));
|
|
68
|
+
} else {
|
|
69
|
+
authTab.launch(authLauncher, url, callback.getScheme());
|
|
70
|
+
}
|
|
71
|
+
} catch (ActivityNotFoundException | IllegalStateException error) {
|
|
72
|
+
clearPendingAuth();
|
|
73
|
+
call.reject("No compatible system browser is available.", NativeContract.ERROR_BROWSER_UNAVAILABLE);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@PluginMethod
|
|
78
|
+
public void cancel(PluginCall call) {
|
|
79
|
+
PluginCall authCall = pendingAuthCall;
|
|
80
|
+
clearPendingAuth();
|
|
81
|
+
if (authCall != null) authCall.reject("The authentication session was cancelled.", NativeContract.ERROR_USER_CANCELLED);
|
|
82
|
+
call.resolve();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@Override
|
|
86
|
+
protected void handleOnNewIntent(Intent intent) {
|
|
87
|
+
Uri callback = intent.getData();
|
|
88
|
+
PluginCall call = pendingAuthCall;
|
|
89
|
+
if (call == null || callback == null || !CallbackUriMatcher.matches(callback, pendingCallback)) return;
|
|
90
|
+
|
|
91
|
+
clearPendingAuth();
|
|
92
|
+
JSObject response = new JSObject();
|
|
93
|
+
response.put(NativeContract.FIELD_URL, callback.toString());
|
|
94
|
+
call.resolve(response);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@PluginMethod
|
|
98
|
+
public void storageSet(PluginCall call) {
|
|
99
|
+
String namespace = requiredString(call, NativeContract.FIELD_NAMESPACE);
|
|
100
|
+
String key = requiredString(call, NativeContract.FIELD_KEY);
|
|
101
|
+
String value = call.getString(NativeContract.FIELD_VALUE);
|
|
102
|
+
if (namespace == null || key == null) return;
|
|
103
|
+
if (value == null) {
|
|
104
|
+
call.reject("value is required.");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
vault.set(namespace, key, value);
|
|
110
|
+
call.resolve();
|
|
111
|
+
} catch (GeneralSecurityException | IOException error) {
|
|
112
|
+
rejectStorage(call);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@PluginMethod
|
|
117
|
+
public void storageGet(PluginCall call) {
|
|
118
|
+
String namespace = requiredString(call, NativeContract.FIELD_NAMESPACE);
|
|
119
|
+
String key = requiredString(call, NativeContract.FIELD_KEY);
|
|
120
|
+
if (namespace == null || key == null) return;
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
resolveValue(call, vault.get(namespace, key));
|
|
124
|
+
} catch (GeneralSecurityException | IOException error) {
|
|
125
|
+
rejectStorage(call);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@PluginMethod
|
|
130
|
+
public void storageRemove(PluginCall call) {
|
|
131
|
+
String namespace = requiredString(call, NativeContract.FIELD_NAMESPACE);
|
|
132
|
+
String key = requiredString(call, NativeContract.FIELD_KEY);
|
|
133
|
+
if (namespace == null || key == null) return;
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
resolveValue(call, vault.remove(namespace, key));
|
|
137
|
+
} catch (GeneralSecurityException | IOException error) {
|
|
138
|
+
rejectStorage(call);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
@PluginMethod
|
|
143
|
+
public void storageGetAllKeys(PluginCall call) {
|
|
144
|
+
String namespace = requiredString(call, NativeContract.FIELD_NAMESPACE);
|
|
145
|
+
if (namespace == null) return;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
List<String> keys = vault.getAllKeys(namespace);
|
|
149
|
+
JSObject result = new JSObject();
|
|
150
|
+
result.put(NativeContract.FIELD_KEYS, new JSArray(keys));
|
|
151
|
+
call.resolve(result);
|
|
152
|
+
} catch (GeneralSecurityException | IOException error) {
|
|
153
|
+
rejectStorage(call);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
@PluginMethod
|
|
158
|
+
public void setSessionSnapshot(PluginCall call) {
|
|
159
|
+
String namespace = requiredString(call, NativeContract.FIELD_NAMESPACE);
|
|
160
|
+
if (namespace == null) return;
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
vault.setSessionSnapshot(namespace, call.getString(NativeContract.FIELD_VALUE));
|
|
164
|
+
call.resolve();
|
|
165
|
+
} catch (GeneralSecurityException | IOException error) {
|
|
166
|
+
rejectStorage(call);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private void handleAuthResult(AuthTabIntent.AuthResult result) {
|
|
171
|
+
PluginCall call = pendingAuthCall;
|
|
172
|
+
Uri expectedCallback = pendingCallback;
|
|
173
|
+
clearPendingAuth();
|
|
174
|
+
if (call == null) return;
|
|
175
|
+
|
|
176
|
+
if (result.resultCode == AuthTabIntent.RESULT_CANCELED) {
|
|
177
|
+
call.reject("The authentication session was cancelled.", NativeContract.ERROR_USER_CANCELLED);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (result.resultCode != AuthTabIntent.RESULT_OK || result.resultUri == null) {
|
|
181
|
+
call.reject("The browser did not return a valid callback.", NativeContract.ERROR_INVALID_CALLBACK);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (!CallbackUriMatcher.matches(result.resultUri, expectedCallback)) {
|
|
185
|
+
call.reject("The authentication callback does not match the configured redirect URL.", NativeContract.ERROR_INVALID_CALLBACK);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
JSObject response = new JSObject();
|
|
190
|
+
response.put(NativeContract.FIELD_URL, result.resultUri.toString());
|
|
191
|
+
call.resolve(response);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private void clearPendingAuth() {
|
|
195
|
+
pendingAuthCall = null;
|
|
196
|
+
pendingCallback = null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private static String requiredString(PluginCall call, String name) {
|
|
200
|
+
String value = call.getString(name);
|
|
201
|
+
if (value == null || value.isEmpty()) {
|
|
202
|
+
call.reject(name + " is required.");
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
return value;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private static boolean isSecureRequestUrl(Uri uri) {
|
|
209
|
+
String scheme = uri.getScheme();
|
|
210
|
+
String host = uri.getHost();
|
|
211
|
+
if (host == null) return false;
|
|
212
|
+
if ("https".equalsIgnoreCase(scheme)) return true;
|
|
213
|
+
return "http".equalsIgnoreCase(scheme) &&
|
|
214
|
+
("localhost".equalsIgnoreCase(host) ||
|
|
215
|
+
"127.0.0.1".equals(host) ||
|
|
216
|
+
"::1".equals(host) ||
|
|
217
|
+
"[::1]".equals(host));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private static boolean isSupportedCallback(Uri uri) {
|
|
221
|
+
String scheme = uri.getScheme();
|
|
222
|
+
if (scheme == null || scheme.isEmpty() || "http".equalsIgnoreCase(scheme)) return false;
|
|
223
|
+
return !"https".equalsIgnoreCase(scheme) || uri.getHost() != null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private static String path(Uri uri) {
|
|
227
|
+
String path = uri.getPath();
|
|
228
|
+
return path == null ? "" : path;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private static void resolveValue(PluginCall call, String value) {
|
|
232
|
+
JSObject result = new JSObject();
|
|
233
|
+
result.put(NativeContract.FIELD_VALUE, value == null ? JSONObject.NULL : value);
|
|
234
|
+
call.resolve(result);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private static void rejectStorage(PluginCall call) {
|
|
238
|
+
call.reject("Secure storage failed.", NativeContract.ERROR_SECURE_STORAGE_ERROR);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Generated by scripts/generate-native-contract.mjs. Do not edit.
|
|
2
|
+
package com.jojopirker.capacitor.oidc;
|
|
3
|
+
|
|
4
|
+
final class NativeContract {
|
|
5
|
+
|
|
6
|
+
static final String PLUGIN_NAME = "CapacitorOidc";
|
|
7
|
+
|
|
8
|
+
static final String METHOD_CONFIGURE = "configure";
|
|
9
|
+
static final String METHOD_OPEN = "open";
|
|
10
|
+
static final String METHOD_CANCEL = "cancel";
|
|
11
|
+
static final String METHOD_STORAGE_SET = "storageSet";
|
|
12
|
+
static final String METHOD_STORAGE_GET = "storageGet";
|
|
13
|
+
static final String METHOD_STORAGE_REMOVE = "storageRemove";
|
|
14
|
+
static final String METHOD_STORAGE_GET_ALL_KEYS = "storageGetAllKeys";
|
|
15
|
+
static final String METHOD_SET_SESSION_SNAPSHOT = "setSessionSnapshot";
|
|
16
|
+
static final String FIELD_URL = "url";
|
|
17
|
+
static final String FIELD_CALLBACK_URL = "callbackUrl";
|
|
18
|
+
static final String FIELD_PREFERS_EPHEMERAL_WEB_BROWSER_SESSION = "prefersEphemeralWebBrowserSession";
|
|
19
|
+
static final String FIELD_KEYCHAIN_ACCESS_GROUP = "keychainAccessGroup";
|
|
20
|
+
static final String FIELD_KEYCHAIN_ACCESSIBILITY = "keychainAccessibility";
|
|
21
|
+
static final String FIELD_NAMESPACE = "namespace";
|
|
22
|
+
static final String FIELD_KEY = "key";
|
|
23
|
+
static final String FIELD_VALUE = "value";
|
|
24
|
+
static final String FIELD_KEYS = "keys";
|
|
25
|
+
static final String ERROR_AUTH_SESSION_IN_PROGRESS = "AUTH_SESSION_IN_PROGRESS";
|
|
26
|
+
static final String ERROR_USER_CANCELLED = "USER_CANCELLED";
|
|
27
|
+
static final String ERROR_BROWSER_UNAVAILABLE = "BROWSER_UNAVAILABLE";
|
|
28
|
+
static final String ERROR_INVALID_CALLBACK = "INVALID_CALLBACK";
|
|
29
|
+
static final String ERROR_SECURE_STORAGE_ERROR = "SECURE_STORAGE_ERROR";
|
|
30
|
+
static final String ERROR_UNSUPPORTED_RUNTIME = "UNSUPPORTED_RUNTIME";
|
|
31
|
+
static final String KEYCHAIN_ACCESSIBILITY_AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY = "afterFirstUnlockThisDeviceOnly";
|
|
32
|
+
static final String KEYCHAIN_ACCESSIBILITY_WHEN_UNLOCKED_THIS_DEVICE_ONLY = "whenUnlockedThisDeviceOnly";
|
|
33
|
+
|
|
34
|
+
private NativeContract() {}
|
|
35
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
package com.jojopirker.capacitor.oidc;
|
|
2
|
+
|
|
3
|
+
import org.json.JSONException;
|
|
4
|
+
import org.json.JSONObject;
|
|
5
|
+
|
|
6
|
+
/** Versioned session data readable by an Android widget in the host application. */
|
|
7
|
+
public final class StoredSessionV1 {
|
|
8
|
+
|
|
9
|
+
public static final int VERSION = 1;
|
|
10
|
+
|
|
11
|
+
private final String issuer;
|
|
12
|
+
private final String clientId;
|
|
13
|
+
private final String accessToken;
|
|
14
|
+
private final String refreshToken;
|
|
15
|
+
private final String idToken;
|
|
16
|
+
private final String tokenType;
|
|
17
|
+
private final String scope;
|
|
18
|
+
private final Long expiresAt;
|
|
19
|
+
|
|
20
|
+
public StoredSessionV1(
|
|
21
|
+
String issuer,
|
|
22
|
+
String clientId,
|
|
23
|
+
String accessToken,
|
|
24
|
+
String refreshToken,
|
|
25
|
+
String idToken,
|
|
26
|
+
String tokenType,
|
|
27
|
+
String scope,
|
|
28
|
+
Long expiresAt
|
|
29
|
+
) {
|
|
30
|
+
this.issuer = issuer;
|
|
31
|
+
this.clientId = clientId;
|
|
32
|
+
this.accessToken = accessToken;
|
|
33
|
+
this.refreshToken = refreshToken;
|
|
34
|
+
this.idToken = idToken;
|
|
35
|
+
this.tokenType = tokenType;
|
|
36
|
+
this.scope = scope;
|
|
37
|
+
this.expiresAt = expiresAt;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public static StoredSessionV1 fromJson(String value) throws JSONException {
|
|
41
|
+
JSONObject json = new JSONObject(value);
|
|
42
|
+
if (json.getInt("version") != VERSION) {
|
|
43
|
+
throw new JSONException("Unsupported session version");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return new StoredSessionV1(
|
|
47
|
+
json.getString("issuer"),
|
|
48
|
+
json.getString("clientId"),
|
|
49
|
+
json.getString("accessToken"),
|
|
50
|
+
optionalString(json, "refreshToken"),
|
|
51
|
+
optionalString(json, "idToken"),
|
|
52
|
+
json.getString("tokenType"),
|
|
53
|
+
optionalString(json, "scope"),
|
|
54
|
+
json.has("expiresAt") ? json.getLong("expiresAt") : null
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public String toJson() throws JSONException {
|
|
59
|
+
JSONObject json = new JSONObject();
|
|
60
|
+
json.put("version", VERSION);
|
|
61
|
+
json.put("issuer", issuer);
|
|
62
|
+
json.put("clientId", clientId);
|
|
63
|
+
json.put("accessToken", accessToken);
|
|
64
|
+
putOptional(json, "refreshToken", refreshToken);
|
|
65
|
+
putOptional(json, "idToken", idToken);
|
|
66
|
+
json.put("tokenType", tokenType);
|
|
67
|
+
putOptional(json, "scope", scope);
|
|
68
|
+
if (expiresAt != null) json.put("expiresAt", expiresAt);
|
|
69
|
+
return json.toString();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
public String getIssuer() {
|
|
73
|
+
return issuer;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
public String getClientId() {
|
|
77
|
+
return clientId;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
public String getAccessToken() {
|
|
81
|
+
return accessToken;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
public String getRefreshToken() {
|
|
85
|
+
return refreshToken;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
public String getIdToken() {
|
|
89
|
+
return idToken;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
public String getTokenType() {
|
|
93
|
+
return tokenType;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
public String getScope() {
|
|
97
|
+
return scope;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public Long getExpiresAt() {
|
|
101
|
+
return expiresAt;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private static String optionalString(JSONObject json, String key) throws JSONException {
|
|
105
|
+
return json.has(key) && !json.isNull(key) ? json.getString(key) : null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private static void putOptional(JSONObject json, String key, String value) throws JSONException {
|
|
109
|
+
if (value != null) json.put(key, value);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
package com.jojopirker.capacitor.oidc;
|
|
2
|
+
|
|
3
|
+
import android.content.Context;
|
|
4
|
+
import android.security.keystore.KeyGenParameterSpec;
|
|
5
|
+
import android.security.keystore.KeyProperties;
|
|
6
|
+
import android.util.AtomicFile;
|
|
7
|
+
import android.util.Base64;
|
|
8
|
+
import java.io.ByteArrayInputStream;
|
|
9
|
+
import java.io.ByteArrayOutputStream;
|
|
10
|
+
import java.io.DataInputStream;
|
|
11
|
+
import java.io.DataOutputStream;
|
|
12
|
+
import java.io.File;
|
|
13
|
+
import java.io.FileInputStream;
|
|
14
|
+
import java.io.FileOutputStream;
|
|
15
|
+
import java.io.IOException;
|
|
16
|
+
import java.nio.charset.StandardCharsets;
|
|
17
|
+
import java.security.GeneralSecurityException;
|
|
18
|
+
import java.security.KeyStore;
|
|
19
|
+
import java.security.MessageDigest;
|
|
20
|
+
import java.util.ArrayList;
|
|
21
|
+
import java.util.Arrays;
|
|
22
|
+
import java.util.Collections;
|
|
23
|
+
import java.util.List;
|
|
24
|
+
import javax.crypto.Cipher;
|
|
25
|
+
import javax.crypto.KeyGenerator;
|
|
26
|
+
import javax.crypto.SecretKey;
|
|
27
|
+
import javax.crypto.spec.GCMParameterSpec;
|
|
28
|
+
import org.json.JSONException;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* App-private token storage that can also be used directly by a widget in the host application.
|
|
32
|
+
* Values are encrypted with an AES-GCM key held by Android Keystore.
|
|
33
|
+
*/
|
|
34
|
+
public final class TokenVault {
|
|
35
|
+
|
|
36
|
+
private static final String KEY_STORE = "AndroidKeyStore";
|
|
37
|
+
private static final String KEY_ALIAS = "com.jojopirker.capacitor.oidc.token-vault.v1";
|
|
38
|
+
private static final String CIPHER = "AES/GCM/NoPadding";
|
|
39
|
+
private static final int MAGIC = 0x434f4944;
|
|
40
|
+
private static final int FORMAT_VERSION = 1;
|
|
41
|
+
private static final String FILE_EXTENSION = ".vault";
|
|
42
|
+
private static final String SNAPSHOT_KEY = "current";
|
|
43
|
+
private static final String SNAPSHOT_NAMESPACE_PREFIX = "session-snapshot:";
|
|
44
|
+
|
|
45
|
+
private final File rootDirectory;
|
|
46
|
+
|
|
47
|
+
public TokenVault(Context context) {
|
|
48
|
+
rootDirectory = new File(context.getNoBackupFilesDir(), "capacitor-oidc");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public synchronized void set(String namespace, String key, String value) throws GeneralSecurityException, IOException {
|
|
52
|
+
File file = entryFile(namespace, key, true);
|
|
53
|
+
byte[] plaintext = VaultEntryCodec.encode(key, value);
|
|
54
|
+
Cipher cipher = Cipher.getInstance(CIPHER);
|
|
55
|
+
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey());
|
|
56
|
+
cipher.updateAAD(aad(namespace));
|
|
57
|
+
byte[] ciphertext = cipher.doFinal(plaintext);
|
|
58
|
+
write(file, cipher.getIV(), ciphertext);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public synchronized String get(String namespace, String key) throws GeneralSecurityException, IOException {
|
|
62
|
+
File file = entryFile(namespace, key, false);
|
|
63
|
+
if (!file.exists()) return null;
|
|
64
|
+
VaultEntryCodec.Entry entry = decrypt(namespace, file);
|
|
65
|
+
if (!entry.key.equals(key)) throw new GeneralSecurityException("Vault key mismatch");
|
|
66
|
+
return entry.value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public synchronized String remove(String namespace, String key) throws GeneralSecurityException, IOException {
|
|
70
|
+
String value = get(namespace, key);
|
|
71
|
+
if (value != null) new AtomicFile(entryFile(namespace, key, false)).delete();
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public synchronized List<String> getAllKeys(String namespace) throws GeneralSecurityException, IOException {
|
|
76
|
+
File directory = namespaceDirectory(namespace);
|
|
77
|
+
File[] files = directory.listFiles((ignored, name) -> name.endsWith(FILE_EXTENSION));
|
|
78
|
+
if (files == null) return Collections.emptyList();
|
|
79
|
+
|
|
80
|
+
Arrays.sort(files);
|
|
81
|
+
List<String> keys = new ArrayList<>(files.length);
|
|
82
|
+
for (File file : files) keys.add(decrypt(namespace, file).key);
|
|
83
|
+
Collections.sort(keys);
|
|
84
|
+
return keys;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
public synchronized void setSessionSnapshot(String namespace, String value) throws GeneralSecurityException, IOException {
|
|
88
|
+
String snapshotNamespace = SNAPSHOT_NAMESPACE_PREFIX + namespace;
|
|
89
|
+
if (value == null) {
|
|
90
|
+
remove(snapshotNamespace, SNAPSHOT_KEY);
|
|
91
|
+
} else {
|
|
92
|
+
set(snapshotNamespace, SNAPSHOT_KEY, value);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
public synchronized String getSessionSnapshot(String namespace) throws GeneralSecurityException, IOException {
|
|
97
|
+
return get(SNAPSHOT_NAMESPACE_PREFIX + namespace, SNAPSHOT_KEY);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public synchronized StoredSessionV1 loadSession(String namespace) throws GeneralSecurityException, IOException, JSONException {
|
|
101
|
+
String value = getSessionSnapshot(namespace);
|
|
102
|
+
return value == null ? null : StoredSessionV1.fromJson(value);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private VaultEntryCodec.Entry decrypt(String namespace, File file) throws GeneralSecurityException, IOException {
|
|
106
|
+
EncryptedValue encrypted = read(file);
|
|
107
|
+
Cipher cipher = Cipher.getInstance(CIPHER);
|
|
108
|
+
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), new GCMParameterSpec(128, encrypted.iv));
|
|
109
|
+
cipher.updateAAD(aad(namespace));
|
|
110
|
+
return VaultEntryCodec.decode(cipher.doFinal(encrypted.ciphertext));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private static synchronized SecretKey getOrCreateKey() throws GeneralSecurityException, IOException {
|
|
114
|
+
KeyStore keyStore = KeyStore.getInstance(KEY_STORE);
|
|
115
|
+
keyStore.load(null);
|
|
116
|
+
SecretKey key = (SecretKey) keyStore.getKey(KEY_ALIAS, null);
|
|
117
|
+
if (key != null) return key;
|
|
118
|
+
|
|
119
|
+
KeyGenerator generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEY_STORE);
|
|
120
|
+
generator.init(
|
|
121
|
+
new KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
|
|
122
|
+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
|
123
|
+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
|
124
|
+
.setKeySize(256)
|
|
125
|
+
.build()
|
|
126
|
+
);
|
|
127
|
+
return generator.generateKey();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private File entryFile(String namespace, String key, boolean createDirectory) throws GeneralSecurityException, IOException {
|
|
131
|
+
File directory = namespaceDirectory(namespace);
|
|
132
|
+
if (createDirectory && !directory.exists() && !directory.mkdirs()) {
|
|
133
|
+
throw new IOException("Unable to create vault directory");
|
|
134
|
+
}
|
|
135
|
+
return new File(directory, digest(key) + FILE_EXTENSION);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private File namespaceDirectory(String namespace) throws GeneralSecurityException {
|
|
139
|
+
return new File(rootDirectory, digest("namespace\u0000" + namespace));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private static String digest(String value) throws GeneralSecurityException {
|
|
143
|
+
byte[] hash = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
|
|
144
|
+
return Base64.encodeToString(hash, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private static byte[] aad(String namespace) {
|
|
148
|
+
return ("capacitor-oidc-v1\u0000" + namespace).getBytes(StandardCharsets.UTF_8);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private static void write(File file, byte[] iv, byte[] ciphertext) throws IOException {
|
|
152
|
+
ByteArrayOutputStream bytes = new ByteArrayOutputStream(iv.length + ciphertext.length + 10);
|
|
153
|
+
DataOutputStream data = new DataOutputStream(bytes);
|
|
154
|
+
data.writeInt(MAGIC);
|
|
155
|
+
data.writeByte(FORMAT_VERSION);
|
|
156
|
+
data.writeByte(iv.length);
|
|
157
|
+
data.write(iv);
|
|
158
|
+
data.writeInt(ciphertext.length);
|
|
159
|
+
data.write(ciphertext);
|
|
160
|
+
|
|
161
|
+
AtomicFile atomicFile = new AtomicFile(file);
|
|
162
|
+
FileOutputStream output = atomicFile.startWrite();
|
|
163
|
+
try {
|
|
164
|
+
output.write(bytes.toByteArray());
|
|
165
|
+
atomicFile.finishWrite(output);
|
|
166
|
+
} catch (IOException error) {
|
|
167
|
+
atomicFile.failWrite(output);
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private static EncryptedValue read(File file) throws IOException {
|
|
173
|
+
AtomicFile atomicFile = new AtomicFile(file);
|
|
174
|
+
byte[] bytes;
|
|
175
|
+
try (FileInputStream input = atomicFile.openRead(); ByteArrayOutputStream output = new ByteArrayOutputStream()) {
|
|
176
|
+
byte[] buffer = new byte[4096];
|
|
177
|
+
int count;
|
|
178
|
+
while ((count = input.read(buffer)) != -1) output.write(buffer, 0, count);
|
|
179
|
+
bytes = output.toByteArray();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
DataInputStream data = new DataInputStream(new ByteArrayInputStream(bytes));
|
|
183
|
+
if (data.readInt() != MAGIC || data.readUnsignedByte() != FORMAT_VERSION) {
|
|
184
|
+
throw new IOException("Unsupported vault format");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
int ivLength = data.readUnsignedByte();
|
|
188
|
+
byte[] iv = new byte[ivLength];
|
|
189
|
+
data.readFully(iv);
|
|
190
|
+
int ciphertextLength = data.readInt();
|
|
191
|
+
if (ciphertextLength < 0 || ciphertextLength != data.available()) {
|
|
192
|
+
throw new IOException("Invalid vault entry");
|
|
193
|
+
}
|
|
194
|
+
byte[] ciphertext = new byte[ciphertextLength];
|
|
195
|
+
data.readFully(ciphertext);
|
|
196
|
+
return new EncryptedValue(iv, ciphertext);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private static final class EncryptedValue {
|
|
200
|
+
|
|
201
|
+
final byte[] iv;
|
|
202
|
+
final byte[] ciphertext;
|
|
203
|
+
|
|
204
|
+
EncryptedValue(byte[] iv, byte[] ciphertext) {
|
|
205
|
+
this.iv = iv;
|
|
206
|
+
this.ciphertext = ciphertext;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
package com.jojopirker.capacitor.oidc;
|
|
2
|
+
|
|
3
|
+
import java.io.ByteArrayInputStream;
|
|
4
|
+
import java.io.ByteArrayOutputStream;
|
|
5
|
+
import java.io.DataInputStream;
|
|
6
|
+
import java.io.DataOutputStream;
|
|
7
|
+
import java.io.IOException;
|
|
8
|
+
import java.nio.charset.StandardCharsets;
|
|
9
|
+
|
|
10
|
+
final class VaultEntryCodec {
|
|
11
|
+
|
|
12
|
+
private VaultEntryCodec() {}
|
|
13
|
+
|
|
14
|
+
static byte[] encode(String key, String value) throws IOException {
|
|
15
|
+
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
|
|
16
|
+
byte[] valueBytes = value.getBytes(StandardCharsets.UTF_8);
|
|
17
|
+
ByteArrayOutputStream bytes = new ByteArrayOutputStream(keyBytes.length + valueBytes.length + 8);
|
|
18
|
+
DataOutputStream output = new DataOutputStream(bytes);
|
|
19
|
+
output.writeInt(keyBytes.length);
|
|
20
|
+
output.write(keyBytes);
|
|
21
|
+
output.writeInt(valueBytes.length);
|
|
22
|
+
output.write(valueBytes);
|
|
23
|
+
return bytes.toByteArray();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
static Entry decode(byte[] bytes) throws IOException {
|
|
27
|
+
DataInputStream input = new DataInputStream(new ByteArrayInputStream(bytes));
|
|
28
|
+
int keyLength = input.readInt();
|
|
29
|
+
if (keyLength < 0 || keyLength > input.available() - 4) {
|
|
30
|
+
throw new IOException("Invalid vault entry");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
byte[] keyBytes = new byte[keyLength];
|
|
34
|
+
input.readFully(keyBytes);
|
|
35
|
+
|
|
36
|
+
int valueLength = input.readInt();
|
|
37
|
+
if (valueLength < 0 || valueLength != input.available()) {
|
|
38
|
+
throw new IOException("Invalid vault entry");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
byte[] valueBytes = new byte[valueLength];
|
|
42
|
+
input.readFully(valueBytes);
|
|
43
|
+
return new Entry(new String(keyBytes, StandardCharsets.UTF_8), new String(valueBytes, StandardCharsets.UTF_8));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static final class Entry {
|
|
47
|
+
|
|
48
|
+
final String key;
|
|
49
|
+
final String value;
|
|
50
|
+
|
|
51
|
+
Entry(String key, String value) {
|
|
52
|
+
this.key = key;
|
|
53
|
+
this.value = value;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { INavigator, IWindow } from 'oidc-client-ts';
|
|
2
|
+
export declare class CapacitorNavigator implements INavigator {
|
|
3
|
+
private readonly prefersEphemeralWebBrowserSession;
|
|
4
|
+
constructor(prefersEphemeralWebBrowserSession: boolean);
|
|
5
|
+
prepare(): Promise<IWindow>;
|
|
6
|
+
callback(): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export declare function assertSecureRequestUrl(requestUrl: string): void;
|
|
9
|
+
export declare function callbackUrlFromRequest(requestUrl: string): string;
|
|
10
|
+
export declare function isExpectedCallback(actualValue: string, expectedValue: string): boolean;
|
|
11
|
+
export declare class UnsupportedIframeNavigator implements INavigator {
|
|
12
|
+
prepare(): Promise<IWindow>;
|
|
13
|
+
callback(): Promise<void>;
|
|
14
|
+
}
|