Repository: moberwasserlechner/capacitor-oauth2
Branch: main
Commit: 97d6864b1531
Files: 48
Total size: 222.0 KB
Directory structure:
gitextract_o6pwcche/
├── .eslintignore
├── .github/
│ ├── CONTRIBUTING.md
│ ├── ISSUE_TEMPLATE/
│ │ ├── -everything-else--report.md
│ │ ├── bug-report.md
│ │ └── feature-request.md
│ └── workflows/
│ └── ci.yml
├── .gitignore
├── CHANGELOG.md
├── CapacitorCommunityGenericOAuth2.podspec
├── LICENSE
├── Package.swift
├── README.md
├── android/
│ ├── .gitignore
│ ├── build.gradle
│ ├── gradle/
│ │ └── wrapper/
│ │ ├── gradle-wrapper.jar
│ │ └── gradle-wrapper.properties
│ ├── gradle.properties
│ ├── gradlew
│ ├── gradlew.bat
│ ├── proguard-rules.pro
│ ├── settings.gradle
│ └── src/
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/
│ │ │ └── com/
│ │ │ └── getcapacitor/
│ │ │ └── community/
│ │ │ └── genericoauth2/
│ │ │ ├── ConfigUtils.java
│ │ │ ├── GenericOAuth2Plugin.java
│ │ │ ├── OAuth2Options.java
│ │ │ ├── OAuth2RefreshTokenOptions.java
│ │ │ ├── OAuth2Utils.java
│ │ │ ├── ResourceCallResult.java
│ │ │ ├── ResourceUrlAsyncTask.java
│ │ │ └── handler/
│ │ │ ├── AccessTokenCallback.java
│ │ │ └── OAuth2CustomHandler.java
│ │ └── res/
│ │ └── .gitkeep
│ └── test/
│ └── java/
│ └── com/
│ └── getcapacitor/
│ └── community/
│ └── genericoauth2/
│ ├── ConfigUtilsTest.java
│ └── GenericOAuth2PluginTest.java
├── ios/
│ ├── .gitignore
│ ├── Sources/
│ │ └── GenericOAuth2Plugin/
│ │ ├── GenericOAuth2Plugin.swift
│ │ ├── OAuth2CustomHandler.swift
│ │ └── OAuth2SafariDelegate.swift
│ └── Tests/
│ └── GenericOAuth2PluginTests/
│ └── GenericOAuth2Tests.swift
├── jest.config.js
├── package.json
├── rollup.config.mjs
├── src/
│ ├── definitions.ts
│ ├── index.ts
│ ├── web-utils.test.ts
│ ├── web-utils.ts
│ └── web.ts
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintignore
================================================
build
dist
================================================
FILE: .github/CONTRIBUTING.md
================================================
# Contributing
I'm happy to accept external contributions to the project in the form of feedback,
bug reports and even better - pull requests
## Issues
Issues are mostly used to track **bugs** and **feature requests** but you can also
ask questions as it's the only place I'm looking at.
Before reporting a bug or requesting a feature, run a few searches to
see if a similar issue has already been opened and ensure you’re not submitting
a duplicate.
### Bugs
* Choose the "Bug Report" template
* Fill in all relevant information, especially
* Describe steps to reproduce
* Full error message if any
* Your code if relevant
### Feature Requests
* Choose the "Feature Request" template
* Describe the feature. Be specific
* Explain why I should implement it.
## Pull Request Guidelines
* Please check to make sure that there aren't existing pull requests attempting to address the issue mentioned.
* Open a single PR for each subject.
* Develop in a topic branch, not main (feature-name).
* Write a convincing description of your PR and why I should land it.
* Update documentation comments where applicable.
### Only touch relevant files
* Make sure your PR stays focused on a single feature.
* Don't change project configs or any files unrelated to the subject you're working.
* Don't reformat code you don't modify.
### Fixing a bug?
* Mention it or create an issue if not exist
* Do not forgot to put [Fix # in your commit message to auto close](https://help.github.com/articles/closing-issues-via-commit-messages/)
### Keep your commit history short and clean.
* Keeping the history clean means making one commit per feature. (no fix of your fix)
* I will squash every PR.
### Make sure tests pass (if exist)
* Add relevant tests to cover the change.
* Make sure test-suite passes.
================================================
FILE: .github/ISSUE_TEMPLATE/-everything-else--report.md
================================================
---
name: '"Everything else" Report'
about: Use this if it's NOT a bug or feature request
title: ''
labels: ''
assignees: ''
---
### Description
### Capacitor version:
Run `npx cap doctor`:
```
Replace this with the commands output
```
### Library version:
- 3.0.1
- 2.1.0
- 2.0.0
- other: (Please fill in the version you are using.)
### OAuth Provider:
- Google
- Facebook
- Azure AD (B2C)
- Github
- Other: (Please fill in the provider you are using.)
### Your Plugin Configuration
```typescript
{
// Replace this with your plugin configuration
}
```
### Affected Platform(s):
* Android
* Version/API Level:
* Device Model:
* Content of your `AndroidManifest.xml`
```xml
```
* iOS
* Version/API Level:
* Device Model:
* Content of your `Info.plist`
```xml
```
* Web
* Browser:
================================================
FILE: .github/ISSUE_TEMPLATE/bug-report.md
================================================
---
name: Bug Report
about: Template to report bugs.
title: 'Bug: '
labels: ''
assignees: ''
---
### Capacitor version:
Run `npx cap doctor`:
```
Replace this with the commands output
```
### Library version:
- 3.0.1
- 2.1.0
- 2.0.0
- other: (Please fill in the version you are using.)
### OAuth Provider:
- Google
- Facebook
- Azure AD (B2C)
- Github
- Other: (Please fill in the provider you are using.)
### Your Plugin Configuration
```typescript
{
// Replace this with your plugin configuration
}
```
### Affected Platform(s):
* Android
* Version/API Level:
* Device Model:
* Content of your `AndroidManifest.xml`
```xml
```
* iOS
* Version/API Level:
* Device Model:
* Content of your `Info.plist`
```xml
```
* Web
* Browser:
### Current Behavior
### Expected Behavior
### Sample Code or Sample Application Repo
### Reproduction Steps
### Other Information
================================================
FILE: .github/ISSUE_TEMPLATE/feature-request.md
================================================
---
name: Feature Request
about: Request a feature addition or change.
title: 'Feat: '
labels: ''
assignees: ''
---
### Describe the Feature
### Platform(s) Support Requested
- Android
- iOS
- Electron
- Web
### Describe Preferred Solution
### Describe Alternatives
### Related Code
### Additional Context
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
push:
branches:
- '**'
tags-ignore:
- '*.*'
paths-ignore:
- README.md
jobs:
test-web:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: actions/checkout@v4
- name: Restore Dependency Cache
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.OS }}-dependency-cache-${{ hashFiles('**/package.json') }}
- run: npm ci
- run: npm run build --if-present
- run: npm test
# test-ios:
# runs-on: macos-latest
# timeout-minutes: 30
# strategy:
# matrix:
# xcode:
# - /Applications/Xcode_12.4.app
# steps:
# - run: sudo xcode-select --switch ${{ matrix.xcode }}
# - uses: actions/setup-node@v1
# with:
# node-version: 14.x
# - uses: actions/checkout@v2
# - name: Restore Dependency Cache
# uses: actions/cache@v1
# with:
# path: ~/.npm
# key: ${{ runner.OS }}-dependency-cache-${{ hashFiles('**/package.json') }}
# - run: npm install
# - run: npm run verify
# working-directory: ./ios
test-android:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: zulu
java-version: 21
- name: Grant execute permission for gradlew
run: chmod +x gradlew
working-directory: ./android
- name: Cache .gradle
uses: actions/cache@v4
with:
path: .gradle
key: ${{ runner.os }}-dotgradle-${{ hashFiles('**/build.gradle') }}
restore-keys: |
${{ runner.os }}-dotgradle-
- name: Cache gradle
uses: actions/cache@v4
with:
path: ~/.gradle
key: ${{ runner.os }}-gradle-${{ hashFiles('**/build.gradle') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Install Capacitor Android dependency
run: npm ci
- name: Run Tests
run: ./gradlew test
working-directory: ./android
================================================
FILE: .gitignore
================================================
# node files
dist
node_modules
# iOS files
Pods
Podfile.lock
Build
xcuserdata
# macOS files
.DS_Store
# Based on Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.ap_
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin
gen
out
# Gradle files
.gradle
build
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation
# Android Studio captures folder
captures
# IntelliJ
*.iml
.idea
# Keystore files
# Uncomment the following line if you do not want to check your keystore files in.
#*.jks
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
# Locally published versions
capacitor-community-generic-oauth2-*.tgz
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
Package.resolved
/.build
================================================
FILE: CHANGELOG.md
================================================
# Changelog
## [6.x.x] - 2024
See [GitHub Releases](https://github.com/capacitor-community/generic-oauth2/releases) for details
## [5.0.0] - 2023-09-04
### Breaking
* Minimum Capacitor version is **5.0.0**.! [#211](https://github.com/moberwasserlechner/capacitor-oauth2/issues/211)
* Remove web option `windowReplace` as it is deprecated and gives build exceptions. See https://www.w3schools.com/jsref/met_win_open.asp for details.
## [4.0.2] - 2023-04-11
### Fixed
* Web: Parse url parameters for search and hash properly [#183](https://github.com/moberwasserlechner/capacitor-oauth2/pull/183), [#182](https://github.com/moberwasserlechner/capacitor-oauth2/issues/182). Thank you, [@jvartanian](https://github.com/jvartanian)
## [4.0.1] - 2023-04-11
### Fixed
* Android: Additional `id_token` argument for logout method [#233](https://github.com/moberwasserlechner/capacitor-oauth2/pull/233). Thank you, [@svzi](https://github.com/svzi)
### Chore
* Update dev dependencies
## [4.0.0] - 2022-09-18
### Fixed
* Detection of Network Errors when refreshing Tokens [#192](https://github.com/moberwasserlechner/capacitor-oauth2/issues/192)
* Popup blocked in Safari for pkce flow [#216](https://github.com/moberwasserlechner/capacitor-oauth2/issues/216)
### Breaking
* Minimum Capacitor version is **4.0.0**.! [#211](https://github.com/moberwasserlechner/capacitor-oauth2/issues/211)
## [3.0.1] - 2021-08-11
### Docs
* Where to securely save tokens [README](https://github.com/moberwasserlechner/capacitor-oauth2/#where-to-store-access-tokens) entry. [#139](https://github.com/moberwasserlechner/capacitor-oauth2/issues/139) Thank you [@RaphaelWoude](https://github.com/RaphaelWoude)
### Changed
* Chore: Use main instead of master branch. [#168](https://github.com/moberwasserlechner/capacitor-oauth2/issues/168)
### Fixed
* Android: Use json for responses instead of string. [#171](https://github.com/moberwasserlechner/capacitor-oauth2/issues/171) Thank you [@webflo](https://github.com/webflo)
## [3.0.0] - 2021-08-02
### Breaking
* Minimum Capacitor version is **3.0.0**. Only this plugin version supports Capacitor `3.x`! [#138](https://github.com/moberwasserlechner/capacitor-oauth2/issues/138) [#140](https://github.com/moberwasserlechner/capacitor-oauth2/pull/140)
### Added
* Web: Add a new option `windowReplace` that defaults to undefined. Used in `window.open()` 4th param.
This will fix https://bugs.chromium.org/p/chromium/issues/detail?id=1164959 [#153](https://github.com/moberwasserlechner/capacitor-oauth2/issues/153)
* Web, Android: Add "authorization_response" and "access_token_response" to the result returned to JS. On iOS it is not possible to extract the authorization response because of the used lib. [#154](https://github.com/moberwasserlechner/capacitor-oauth2/issues/154)
* Web, Android: Added `additionalResourceHeaders` to base options
* Web, Android, iOS: Added `logsEnabled` to base options. If enabled extensive logs are written. All logs are prefixed with `I/Capacitor/OAuth2ClientPlugin` across all platforms.
### Changed
* Use `window.crypto` if available to generate random strings [#138](https://github.com/moberwasserlechner/capacitor-oauth2/issues/138) [#140](https://github.com/moberwasserlechner/capacitor-oauth2/pull/140)
### Fixed
* Web: # in URL causes parser to ignore ? [#132](https://github.com/moberwasserlechner/capacitor-oauth2/issues/132) [#133](https://github.com/moberwasserlechner/capacitor-oauth2/pull/133)
* Android: Fix boolean param inheritance (#162) [#162](https://github.com/moberwasserlechner/capacitor-oauth2/issues/162)
## [2.1.0] - 2020-08-27
### Added
* ios: Sign in with Apple. Closes [#45](https://github.com/moberwasserlechner/capacitor-oauth2/issues/45).
The plugin will detect that the iOS 13+ buildin UI is needed, when `authorizationBaseUrl` contains `appleid.apple.com`.
This is needed for other platforms and iOS <=12 anyway. Android, web, iOS <12 are not supported in this release.
### Fixed
* web: Make web flow work if server and client are hosted on same server. Closes [#94](https://github.com/moberwasserlechner/capacitor-oauth2/issues/94). thx [@klot-git](https://github.com/klot-git)
### Changed
* iOS: Upgrade SwiftOAuth2 to head. Closes [#105](https://github.com/moberwasserlechner/capacitor-oauth2/issues/105)
## [2.0.0] - 2020-04-20
### Breaking
* Core: Capacitor 2.x is new minimum peer dependency. closes #80.
* `responseType` is required. Default values were removed. In favor of configuring anything. closes #86.
* `pkceDisabled` was replaced with `pkceEnabled`, which is NOT enabled by default. If you like to use PKCE set this to true.
* If a flow must not have a `accessTokenEndpoint` but you configured one as base parameter you have to
overwrite it in the according platform sections. `accessTokenEndpoint: ""` see Google example in README.
* Add `redirectUrl` to base parameter and make it overwritable in the platform sections. closes #84.
* Android: `customScheme` replaced by `redirectUrl`
* iOS: `customScheme` replaced by `redirectUrl`
* Additional method argument for `OAuth2CustomHandler#logout`. closes #58
* Android: `activity` as 1st argument
* iOS: `viewController` as 1st argument
### Added
* iOS: If the user touches "done" in safari without entering the credentials
the USER_CANCELLED error is sent. closes #71
* Web: Include all url params from the accessToken request if no resourceUrl is present. closes #72. thx [@sanjaywadhwani](https://github.com/sanjaywadhwani)
* Android: Add an alternative to handle the activity result intent.
This is controlled by Android specific parameters `handleResultOnNewIntent` for the alternative and `handleResultOnActivityResult` for the default. closes #52, #55.
### Changed
* Android: Allow no resource url and just return every we got until so far. closes #75. thx [@0x4AMiller](https://github.com/0x4AMiller)
* Web, iOS, Android: All base parameters are overwritable in the platform sections. closes #84.
* Restriction to the response type `code` and `token` was removed. Devs can configure anything but are responsible for it as well. closes #86.
### Fixed
* iOS: XCode 11.4 crash on app start. closes #73. thx [@macdja38](https://github.com/macdja38)
### Docs
* CustomHandler Facebook example logout fixed. closes #79. thx [@REPTILEHAUS](https://github.com/REPTILEHAUS)
* Facebook force authentication with FB App. closes #69. thx [@mrbatista](https://github.com/mrbatista)
## [1.1.0] - 2020-01-22
### Changed
- Docs for Facebook if using iOS 13 and Facebook pod 5.x #56
- Align Android behavior to iOS where the additional parameters are not overwritten #57 (thx @maggix)
- Upgrade dev dependencies to Capacitor 1.4.0
### Added
- Refresh token feature for iOS and Android #64 (thx @dennisameling)
- Detect when user cancels authentication on web (implicit flow) #25 (thx @michaeltintiuc)
## [1.0.1] - 2019-09-19
### Added
- Add OpenID not supported to README
- Add CHANGELOG file to project
### Fixed
- web/pwa: `pkceCodeChallenge` was always `undefined` because promise was not awaited properly #53 (thx @nicksteenstra)
## [1.0.0] - 2019-06-26
### Added
- Add minimum cap version to installation notice
### Changed
- Upgrade to Capacitor 1.0.0 #43,#39
### Fixed
- Android: Fix plugin does not send resource url response to app after specific steps #28
- Android: Fix Java compiler error #36 (thx @Anthbs)
- Fix github security error by updating Jest lib
[Unreleased]: https://github.com/moberwasserlechner/capacitor-oauth2/compare/5.0.0...main
[5.0.0]: https://github.com/moberwasserlechner/capacitor-oauth2/compare/4.0.2...5.0.0
[4.0.2]: https://github.com/moberwasserlechner/capacitor-oauth2/compare/4.0.1...4.0.2
[4.0.1]: https://github.com/moberwasserlechner/capacitor-oauth2/compare/4.0.0...4.0.1
[4.0.0]: https://github.com/moberwasserlechner/capacitor-oauth2/compare/3.0.1...4.0.0
[3.0.1]: https://github.com/moberwasserlechner/capacitor-oauth2/compare/3.0.0...3.0.1
[3.0.0]: https://github.com/moberwasserlechner/capacitor-oauth2/compare/2.1.0...3.0.0
[2.1.0]: https://github.com/moberwasserlechner/capacitor-oauth2/compare/2.0.0...2.1.0
[2.0.0]: https://github.com/moberwasserlechner/capacitor-oauth2/compare/1.1.0...2.0.0
[1.1.0]: https://github.com/moberwasserlechner/capacitor-oauth2/compare/1.0.1...1.1.0
[1.0.1]: https://github.com/moberwasserlechner/capacitor-oauth2/compare/1.0.0...1.0.1
[1.0.0]: https://github.com/moberwasserlechner/capacitor-oauth2/releases/tag/1.0.0
================================================
FILE: CapacitorCommunityGenericOAuth2.podspec
================================================
require 'json'
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
Pod::Spec.new do |s|
s.name = 'CapacitorCommunityGenericOauth2'
s.version = package['version']
s.summary = package['description']
s.license = package['license']
s.homepage = package['repository']['url']
s.author = package['author']
s.source = { :git => package['repository']['url'], :tag => s.version.to_s }
s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
s.ios.deployment_target = '14.0'
s.dependency 'Capacitor'
s.dependency 'OAuthSwift', '2.2.0'
s.swift_version = '5.1'
end
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2025 Capacitor Community
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: Package.swift
================================================
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "CapacitorCommunityGenericOauth2",
platforms: [.iOS(.v14)],
products: [
.library(
name: "CapacitorCommunityGenericOauth2",
targets: ["CapacitorCommunityGenericOauth2"])
],
dependencies: [
.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "7.0.0"),
.package(url: "https://github.com/OAuthSwift/OAuthSwift.git", from: "2.2.0")
],
targets: [
.target(
name: "CapacitorCommunityGenericOauth2",
dependencies: [
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm"),
.product(name: "OAuthSwift", package: "OAuthSwift")
],
path: "ios/Sources/GenericOAuth2Plugin"),
.testTarget(
name: "GenericOAuth2PluginTests",
dependencies: ["CapacitorCommunityGenericOauth2"],
path: "ios/Tests/GenericOAuth2PluginTests")
]
)
================================================
FILE: README.md
================================================
Generic OAuth 2
@capacitor-community/generic-oauth2
Generic Capacitor OAuth 2 client plugin.
## Introduction
This is a **generic OAuth 2 client** plugin. It lets you configure the oauth parameters yourself instead of using SDKs.
Therefore, it is usable with various providers. See [identity providers](#list-of-providers) the community has already
used this plugin with.
## Installation
```bash
npm install @capacitor-community/generic-oauth2
npx cap sync
```
## Versions
| Plugin | For Capacitor | Docs | Notes |
|--------|---------------|---------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|
| 7.x | 7.x.x | [README](./README.md) | Xcode 16.0+ required! |
| 6.x | 6.x.x | [README](https://github.com/capacitor-community/generic-oauth2/blob/6.1.0/README.md) | Breaking changes. As of this version the changelog will be available in the Releases tab in GitHub. XCode 15.0 needs this version |
| 5.x | 5.x.x | [README](https://github.com/capacitor-community/generic-oauth2/blob/5.0.0/README.md) | Breaking changes see Changelog. XCode 14.1 needs this version |
## Supported flows
See the excellent article about OAuth2 response type combinations.
https://medium.com/@darutk/diagrams-of-all-the-openid-connect-flows-6968e3990660
The plugin on the other will behave differently depending on the existence of certain config parameters:
These parameters are:
- `accessTokenEndpoint`
- `resourceUrl`
e.g.
1. If `responseType=code`, `pkceDisable=true` and `accessTokenEndpoint` is missing the `authorizationCode` will be
resolve along with the whole authorization response.
This only works for the Web and Android. On iOS the used lib does not allows to cancel after the authorization
request see #13.
2. If you just need the `id_token` JWT you have to set `accessTokenEndpoint` and `resourceUrl` to `null`.
### Tested / working flows
These flows are already working and were tested by me.
#### Implicit flow
```
responseType: "token"
```
#### Code flow + PKCE
```
...
responseType: "code"
pkceEnabled: true
...
```
Supported on Web with the new method `redirectFlowCodeListener` which should be called on your app init process
so it watches for the URL queryString `code` to generate an `access_token` correctly.
Please be aware that some providers (OneDrive, Auth0) allow **Code Flow + PKCE** only for native apps. Web apps have to
use implicit flow.
### Important
For security reasons this plugin does/will not support Code Flow without PKCE.
That would include storing your **client secret** in client code which is highly insecure and not recommended.
That flow should only be used on the backend (server).
## Configuration
### Use it
```typescript
import {GenericOAuth2} from '@capacitor-community/generic-oauth2';
@Component({
template:
'Login with OAuth ' +
'Refresh token ',
})
export class SignupComponent {
accessToken: string;
refreshToken: string;
onOAuthBtnClick() {
GenericOAuth2.authenticate(oauth2Options)
.then(response => {
this.accessToken = response['access_token'];
this.refreshToken = response['refresh_token'];
// only if you include a resourceUrl protected user values are included in the response!
let oauthUserId = response['id'];
let name = response['name'];
// go to backend
})
.catch(reason => {
console.error('OAuth rejected', reason);
});
}
// Refreshing tokens only works on iOS/Android for now
onOAuthRefreshBtnClick() {
if (!this.refreshToken) {
console.error('No refresh token found. Log in with OAuth first.');
}
GenericOAuth2.refreshToken(oauth2RefreshOptions)
.then(response => {
this.accessToken = response['access_token'];
// Don't forget to store the new refresh token as well!
this.refreshToken = response['refresh_token'];
// Go to backend
})
.catch(reason => {
console.error('Refreshing token failed', reason);
});
}
}
```
### Options
See the `oauth2Options` and `oauth2RefreshOptions` interfaces
at https://github.com/capacitor-community/generic-oauth2/blob/main/src/definitions.ts for details.
Example:
```
{
authorizationBaseUrl: "https://accounts.google.com/o/oauth2/auth",
accessTokenEndpoint: "https://www.googleapis.com/oauth2/v4/token",
scope: "email profile",
resourceUrl: "https://www.googleapis.com/userinfo/v2/me",
logsEnabled: true,
web: {
appId: environment.oauthAppId.google.web,
responseType: "token", // implicit flow
accessTokenEndpoint: "", // clear the tokenEndpoint as we know that implicit flow gets the accessToken from the authorizationRequest
redirectUrl: "http://localhost:4200",
windowOptions: "height=600,left=0,top=0"
},
android: {
appId: environment.oauthAppId.google.android,
responseType: "code", // if you configured a android app in google dev console the value must be "code"
redirectUrl: "com.companyname.appname:/" // package name from google dev console
},
ios: {
appId: environment.oauthAppId.google.ios,
responseType: "code", // if you configured a ios app in google dev console the value must be "code"
redirectUrl: "com.companyname.appname:/" // Bundle ID from google dev console
}
}
```
#### authenticate()
**Overrideable Base Parameter**
These parameters are overrideable in every platform
| parameter | default | required | description | since |
|---------------------------|---------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------|
| appId | | yes | aka clientId, serviceId, ... | |
| authorizationBaseUrl | | yes | | |
| responseType | | yes | | |
| redirectUrl | | yes | | 2.0.0 |
| accessTokenEndpoint | | | If empty the authorization response incl code is returned. Known issue: Not on iOS! | |
| resourceUrl | | | If empty the tokens are return instead. If you need just the `id_token` you have to set both `accessTokenEndpoint` and `resourceUrl` to `null` or empty ``. | |
| additionalResourceHeaders | | | Additional headers for the resource request | 3.0.0 |
| pkceEnabled | `false` | | Enable PKCE if you need it. Note: On iOS because of #111 boolean values are not overwritten. You have to explicitly define the param in the subsection. | |
| logsEnabled | `false` | | Enable extensive logging. All plugin outputs are prefixed with `I/Capacitor/GenericOAuth2Plugin: ` across all platforms. Note: On iOS because of #111 boolean values are not overwritten. You have to explicitly define the param in the subsection. | 3.0.0 |
| scope | | | | |
| state | | | The plugin always uses a state. If you don't provide one we generate it. | |
| additionalParameters | | | Additional parameters for anything you might miss, like `none`, `response_mode`. Just create a key value pair. `{ "key1": "value", "key2": "value, "response_mode": "value"}` | |
**Platform Web**
| parameter | default | required | description | since |
|------------------------|----------|----------|-------------------------------------------------------------------------------------------------|-------|
| windowOptions | | | e.g. width=500,height=600,left=0,top=0 | |
| windowTarget | `_blank` | | | |
| windowReplace | | | | 3.0.0 |
| sendCacheControlHeader | true | | Whether to send the cache control header with the token request, unsupported by some providers. | 6.1.0 |
**Platform Android**
| parameter | default | required | description | since |
|------------------------------|---------|----------|--------------------------------------------------------------------------------------------------------------------------|-------|
| customHandlerClass | | | Provide a class name implementing `com.getcapacitor.community.genericoauth2.handler.OAuth2CustomHandler` | |
| handleResultOnNewIntent | `false` | | Alternative to handle the activity result. The `onNewIntent` method is only call if the App was killed while logging in. | |
| handleResultOnActivityResult | `true` | | | |
**Platform iOS**
| parameter | default | required | description | since |
|--------------------|---------|----------|------------------------------------------------------------------------------------------------|-------|
| customHandlerClass | | | Provide a class name implementing `CapacitorCommunityGenericOauth2.OAuth2CustomHandler` | |
| siwaUseScope | | | SiWA default scope is `name email` if you want to use the configured one set this param `true` | 2.1.0 |
#### logout()
The existing `logout()` method has some issues and is not currently functional.
See [Issue #97](https://github.com/capacitor-community/generic-oauth2/issues/97) for possible workarounds.
#### refreshToken()
| parameter | default | required | description | since |
|---------------------|---------|----------|------------------------------|-------|
| appId | | yes | aka clientId, serviceId, ... | |
| accessTokenEndpoint | | yes | | |
| refreshToken | | yes | | |
| scope | | | | |
### Error Codes
#### authenticate()
- ERR_PARAM_NO_APP_ID ... The appId / clientId is missing. (web, android, ios)
- ERR_PARAM_NO_AUTHORIZATION_BASE_URL ... The authorization base url is missing. (web, android, ios)
- ERR_PARAM_NO_RESPONSE_TYPE ... The response type is missing. (web, android, ios)
- ERR_PARAM_NO_REDIRECT_URL ... The redirect url is missing. (web, android, ios)
- ERR_STATES_NOT_MATCH ... The state included in the authorization code request does not match the one in the redirect.
Security risk! (web, android, ios)
- ERR_AUTHORIZATION_FAILED ... The authorization failed.
- ERR_NO_ACCESS_TOKEN ... No access_token found. (web, android)
- ERR_NO_AUTHORIZATION_CODE ... No authorization code was returned in the redirect response. (web, android, ios)
- USER_CANCELLED ... The user cancelled the login flow. (web, android, ios)
- ERR_CUSTOM_HANDLER_LOGIN ... Login through custom handler class failed. See logs and check your code. (android, ios)
- ERR_CUSTOM_HANDLER_LOGOUT ... Logout through custom handler class failed. See logs and check your code. (android, ios)
- ERR_ANDROID_NO_BROWSER ... No suitable browser could be found! (Android)
- ERR_ANDROID_RESULT_NULL ... The auth result is null. The intent in the ActivityResult is null. This might be a valid
state but make sure you configured Android part correctly! See [Platform Android](#platform-android)
- ERR_GENERAL ... A unspecific error. Check the logs to see want exactly happened. (web, android, ios)
#### refreshToken()
- ERR_PARAM_NO_APP_ID ... The appId / clientId is missing. (android, ios)
- ERR_PARAM_NO_ACCESS_TOKEN_ENDPOINT ... The access token endpoint url is missing. It is only needed on refresh, on
authenticate it is optional. (android, ios)
- ERR_PARAM_NO_REFRESH_TOKEN ... The refresh token is missing. (android, ios)
- ERR_NO_ACCESS_TOKEN ... No access_token found. (web, android)
- ERR_GENERAL ... A unspecific error. Check the logs to see want exactly happened. (android, ios)
## Platform: Web/PWA
This implementation just opens a browser window to let users enter their credentials.
As there is no provider SDK used to accomplish OAuth, no additional javascript files must be loaded and so there is no
performance
impact using this plugin in a web application.
## Platform: Android
There are two options when configuring an OAuth 2 protocol:
1. Some OAuth providers allow using their service _without_ implementing their SDK. For these providers, you can use the
default config available,
2. Other OAuth providers (e.g. Facebook) force developers to use their SDK. For these providers, you can implement a
_Custom OAuth Handler_.
### 1. Android Default Config
> [!NOTE]
> You can skip this, if you're only exclusively configuring providers using a _Custom OAuth Handler_.
> [!NOTE]
> For more information about configuring your Android app, refer to
> the [offical Capacitor documentation](https://capacitor.ionicframework.com/docs/android/configuration)
#### android/app/src/main/res/AndroidManifest.xml
The `AndroidManifest.xml` in your Capacitor Android project already contains
```xml
```
Find the following line in your `AndroidManifest.xml`
```xml
```
and change it to
```xml
```
> [!NOTE]
> Actually any value for `android:host` will do. It does not have to be `oauth`.
>
> This will fix an issues within the oauth workflow where the application is shown twice.
>
> See [Issue #15](https://github.com/capacitor-community/generic-oauth2/issues/15) for details what happens.
#### android/app/src/main/res/values/strings.xml
In your `strings.xml` change the `custom_url_scheme` string to your actual scheme value. Do NOT include
`://oauth/redirect` or other endpoint urls here!
```xml
com.example.yourapp
```
#### android/app/build.gradle
```groovy
android.defaultConfig.manifestPlaceholders = [
// change to the 'custom_url_scheme' value in your strings.xml. They need to be the same. e.g.
"appAuthRedirectScheme": "com.example.yourapp"
]
```
**Troubleshooting**
1. If your `appAuthRedirectScheme` does not get recognized because you are using a library that replaces it
(e.g.: onesignal-cordova-plugin), you will have to add it to your `buildTypes` like the following:
```groovy
android.buildTypes.debug.manifestPlaceholders = [
'appAuthRedirectScheme': '<@string/custom_url_scheme from string.xml>' // e.g. com.companyname.appname
]
android.buildTypes.release.manifestPlaceholders = [
'appAuthRedirectScheme': '<@string/custom_url_scheme from string.xml>' // e.g. com.companyname.appname
]
```
2. "ERR_ANDROID_RESULT_NULL":
See [Issue #52](https://github.com/capacitor-community/generic-oauth2/issues/52#issuecomment-525715515) for details.
I cannot reproduce this behaviour. Moreover, there might be situation this state is valid. In other cases e.g. in the
linked issue a configuration tweak fixed it.
3. To prevent some logout issues on certain OAuth2 providers (like Salesforce for example), you should provide the
`id_token` parameter on the `logout(...)` function.
This ensures that not only the cookies are deleted, but also the logout link is called from the OAuth2 provider.
Also, it uses the system browser that the plugin uses (and not the user's default browser) to call the logout URL.
This additionally ensures that the cookies are deleted in the correct browser.
### 2. Custom OAuth Handler
Some OAuth providers (e.g. Facebook) force developers to use their SDK.
This plugin should be as generic as possible, so I don't want to include provider specific dependencies.
Therefore, I created a mechanism which let developers integrate custom SDK features in this plugin.
Simply configure a full qualified classname in the option property `android.customHandlerClass`.
This class has to implement `com.getcapacitor.community.genericoauth2.handler.OAuth2CustomHandler`.
Refer to the [Facebook example below](#facebook) for a reference implementation.
## Platform: iOS
There are two options when configuring an OAuth 2 protocol:
1. Some OAuth providers allow using their service _without_ implementing their SDK. For these providers, you can use the
default config available,
2. Other OAuth providers (e.g. Facebook) force developers to use their SDK. For these providers, you can implement a
_Custom OAuth Handler_.
### 1. iOS Default Config
> [!NOTE]
> You can skip this, if you're only exclusively configuring providers using a _Custom OAuth Handler_.
Open `ios/App/App/Info.plist` in XCode (Context menu -> Open as -> Source) and add the value of `redirectUrl` from your
config without `:/` like that
```xml
CFBundleURLTypes
CFBundleURLSchemes
com.companyname.appname
```
### 2. Custom OAuth Handler
Some OAuth providers (e.g. Facebook) force developers to use their SDK.
This plugin should be as generic as possible, so I don't want to include provider specific dependencies.
Therefore, I created a mechanism which let developers integrate custom SDK features in this plugin.
Simply configure the class name in the option property `ios.customHandlerClass`.
This class has to implement `CapacitorCommunityGenericOauth2.OAuth2CustomHandler`.
Refer to the [Facebook example below](#facebook) for a reference implementation.
## Platform: Electron
- No timeline.
## Where to store access tokens?
You can use the [capacitor-secure-storage](https://www.npmjs.com/package/capacitor-secure-storage-plugin) plugin for
this.
This plugin stores data in secure locations for natives devices.
- For Android, it will store data in a [`AndroidKeyStore`](https://developer.android.com/training/articles/keystore) and
a [`SharedPreferences`](https://developer.android.com/reference/android/content/SharedPreferences).
- For iOS, it will store data in a [`SwiftKeychainWrapper`](https://github.com/jrendel/SwiftKeychainWrapper).
## List of Providers
These are some of the providers that can be configured with this plugin. I'm happy to add others ot the list, if you let
me know.
| Name | Example (config,...) | Notes |
|----------|----------------------------------------------------|----------|
| Google | [see below](#google) | |
| Facebook | [see below](#facebook) | |
| Azure | [see below](#azure-active-directory--azure-ad-b2c) | |
| Apple | [see below](#apple) | ios only |
## Examples
### Apple
#### iOS 13+
Minimum config
```typescript
appleLogin()
{
GenericOAuth2.authenticate({
appId: "xxxxxxxxx",
authorizationBaseUrl: "https://appleid.apple.com/auth/authorize",
});
}
```
The plugin requires `authorizationBaseUrl` as it triggers the native support and because it is needed for other
platforms anyway. Those platforms are not supported yet.
`appId` is required as well for internal, generic reasons and any not blank value is fine.
It is also possible to control the scope although Apple only supports `email` and/or `fullName`. Add
`siwaUseScope: true` to the ios section.
Then you can use `scope: "fullName"`, `scope: "email"` or both but the latter is the default one if `siwaUseScope` is
not set or false.
```typescript
appleLogin()
{
GenericOAuth2.authenticate({
appId: "xxxxxxxxx",
authorizationBaseUrl: "https://appleid.apple.com/auth/authorize",
ios: {
siwaUseScope: true,
scope: "fullName"
}
});
}
```
As "Signin with Apple" is only supported since iOS 13 you should show the according button only in that case.
In Angular do sth like
```typescript
import {Component, OnInit} from '@angular/core';
import {Device, DeviceInfo} from '@capacitor/device';
import {GenericOAuth2} from '@capacitor-community/generic-oauth2';
@Component({
templateUrl: './siwa.component.html',
})
export class SiwaComponent implements OnInit {
ios: boolean;
siwaSupported: boolean;
deviceInfo: DeviceInfo;
async ngOnInit() {
this.deviceInfo = await Device.getInfo();
this.ios = this.deviceInfo.platform === 'ios';
if (this.ios) {
const majorVersion: number = +this.deviceInfo.osVersion.split('.')[0];
this.siwaSupported = majorVersion >= 13;
}
}
}
```
And show the button only if `siwaSupported` is `true`.
The response contains these fields:
```
"id"
"given_name"
"family_name"
"email"
"real_user_status"
"state"
"id_token"
"code"
```
#### iOS <12
not supported
#### PWA
not supported
#### Android
not supported
### Azure Active Directory / Azure AD B2C
It's important to use the urls you see in the Azure portal for the specific platform.
Note: Don't be confused by the fact that the Azure portal shows "Azure Active Directory" and "Azure AD B2C" services.
They share the same core features and therefore the plugin should work either way.
#### PWA
```typescript
import {
OAuth2AuthenticateOptions,
GenericOAuth2,
} from '@capacitor-community/generic-oauth2';
export class AuthService {
getAzureB2cOAuth2Options(): OAuth2AuthenticateOptions {
return {
appId: environment.oauthAppId.azureBc2.appId,
authorizationBaseUrl: `https://login.microsoftonline.com/${environment.oauthAppId.azureBc2.tenantId}/oauth2/v2.0/authorize`,
scope: 'https://graph.microsoft.com/User.Read', // See Azure Portal -> API permission
accessTokenEndpoint: `https://login.microsoftonline.com/${environment.oauthAppId.azureBc2.tenantId}/oauth2/v2.0/token`,
resourceUrl: 'https://graph.microsoft.com/v1.0/me/',
responseType: 'code',
pkceEnabled: true,
logsEnabled: true,
web: {
redirectUrl: environment.redirectUrl,
windowOptions: 'height=600,left=0,top=0',
},
android: {
redirectUrl: 'msauth://{package-name}/{url-encoded-signature-hash}', // See Azure Portal -> Authentication -> Android Configuration "Redirect URI"
},
ios: {
pkceEnabled: true, // workaround for bug #111
redirectUrl: 'msauth.{package-name}://auth',
},
};
}
}
```
##### Custom Scopes
If you need to use **custom scopes** configured in "API permissions" and created in "Expose an API" in Azure Portal you
might need
to remove the `resourceUrl` parameter if your scopes are not included in the response. I can not give a clear advise on
those Azure specifics.
Try to experiment with the config until Azure includes everything you need in the response.
A configuration with custom scopes might look like this:
```typescript
import {GenericOAuth2} from "@capacitor-community/generic-oauth2";
getAzureB2cOAuth2Options()
:
OAuth2AuthenticateOptions
{
return {
appId: environment.oauthAppId.azureBc2.appId,
authorizationBaseUrl: `https://login.microsoftonline.com/${environment.oauthAppId.azureBc2.tenantId}/oauth2/v2.0/authorize`,
scope: "api://uuid-created-by-azure/scope.name1 api://uuid-created-by-azure/scope.name2", // See Azure Portal -> API permission / Expose an API
accessTokenEndpoint: `https://login.microsoftonline.com/${environment.oauthAppId.azureBc2.tenantId}/oauth2/v2.0/token`,
// no resourceURl!
responseType: "code",
pkceEnabled: true,
logsEnabled: true,
web: {
redirectUrl: environment.redirectUrl,
windowOptions: "height=600,left=0,top=0",
},
android: {
redirectUrl: "msauth://{package-name}/{url-encoded-signature-hash}" // See Azure Portal -> Authentication -> Android Configuration "Redirect URI"
},
ios: {
pkceEnabled: true, // workaround for bug #111
redirectUrl: "msauth.{package-name}://auth"
}
};
}
}
```
##### Prior configs
Other configs that works in prior versions
```typescript
import {GenericOAuth2} from "@capacitor-community/generic-oauth2";
azureLogin()
{
GenericOAuth2.authenticate({
appId: "xxxxxxxxx",
authorizationBaseUrl: "https://tenantb2c.b2clogin.com/tfp/tenantb2c.onmicrosoft.com/B2C_1_SignUpAndSignIn/oauth2/v2.0/authorize",
accessTokenEndpoint: "",
scope: "openid offline_access https://tenantb2c.onmicrosoft.com/capacitor-api/demo.read",
responseType: "token",
web: {
redirectUrl: "http://localhost:8100/auth"
},
android: {
pkceEnabled: true,
responseType: "code",
redirectUrl: "com.tenant.app://oauth/auth", // Use the value from Azure config. Platform "Android"
accessTokenEndpoint: "https://tenantb2c.b2clogin.com/tfp/tenantb2c.onmicrosoft.com/B2C_1_SignUpAndSignIn/oauth2/v2.0/token",
handleResultOnNewIntent: true,
handleResultOnActivityResult: true
},
ios: {
pkceEnabled: true,
responseType: "code",
redirectUrl: "msauth.BUNDLE_ID://oauth", // Use the value from Azure config. Platform "iOS/Mac"
accessTokenEndpoint: "https://tenantb2c.b2clogin.com/tfp/tenantb2c.onmicrosoft.com/B2C_1_SignUpAndSignIn/oauth2/v2.0/token",
}
});
}
```
```typescript
import {GenericOAuth2} from "@capacitor-community/generic-oauth2";
azureLogin()
{
GenericOAuth2.authenticate({
appId: 'XXXXXXXXXX-XXXXXXXXXX-XXXXXXXXX',
authorizationBaseUrl: 'https://TENANT.b2clogin.com/tfp/TENANT.onmicrosoft.com/B2C_1_policy-signin-signup-web/oauth2/v2.0/authorize',
accessTokenEndpoint: '',
scope: 'https://XXXXXXX.onmicrosoft.com/TestApi4/demo.read',
responseType: 'token',
web: {
redirectUrl: 'http://localhost:8100/'
},
android: {
pkceEnabled: true,
responseType: 'code',
redirectUrl: 'com.company.project://oauth/redirect',
accessTokenEndpoint: 'https://TENANT.b2clogin.com/TENANT.onmicrosoft.com/B2C_1_policy-signin-signup-web',
handleResultOnNewIntent: true,
handleResultOnActivityResult: true
},
ios: {
pkceEnabled: true,
responseType: 'code',
redirectUrl: 'com.company.project://oauth',
accessTokenEndpoint: 'https://TENANT.b2clogin.com/TENANT.onmicrosoft.com/B2C_1_policy-signin-signup-web',
}
});
}
```
#### Android
If you have **only** Azure B2C as identity provider you have to add a new `intent-filter` to your main activity in
`AndroidManifest.xml`.
```xml
```
If you have **multiple** identity providers **or** your logins always ends in a `USER_CANCELLED` error like
in [#178](https://github.com/capacitor-community/generic-oauth2/issues/178)
you have to create an additional Activity in `AndroidManifest.xml`.
These are both activities! Make sure to replace `com.company.project.MainActivity` with your real qualified class path!
```xml
```
Values for `android/app/src/main/res/values/string.xml`. Replace the example values!
```
Your Project's Name/string>
com.company.project
foo
com.company.project
msauth
/your-signature-hash
```
See [Android Default Config](#android-default-config)
#### iOS
Open `Info.plist` in XCode by clicking right on that file -> Open as -> Source Code. Note: XCode does not "like" files
opened and changed externally.
```xml
CFBundleURLTypes
CFBundleURLSchemes
msauth.com.yourcompany.yourproject
```
**Important:**
- Do not enter `://` as part of your redirect url
- Make sure the `msauth.` prefix is present
#### Troubleshooting
In case of problems please read [#91](https://github.com/capacitor-community/generic-oauth2/issues/91)
and [#96](https://github.com/capacitor-community/generic-oauth2/issues/96)
See this [example repo](https://github.com/loonix/capacitor-oauth2-azure-example) by @loonix.
### Google
#### PWA
```typescript
import {GenericOAuth2} from "@capacitor-community/generic-oauth2";
googleLogin()
{
GenericOAuth2.authenticate({
authorizationBaseUrl: "https://accounts.google.com/o/oauth2/auth",
accessTokenEndpoint: "https://www.googleapis.com/oauth2/v4/token",
scope: "email profile",
resourceUrl: "https://www.googleapis.com/userinfo/v2/me",
web: {
appId: environment.oauthAppId.google.web,
responseType: "token", // implicit flow
accessTokenEndpoint: "", // clear the tokenEndpoint as we know that implicit flow gets the accessToken from the authorizationRequest
redirectUrl: "http://localhost:4200",
windowOptions: "height=600,left=0,top=0"
},
android: {
appId: environment.oauthAppId.google.android,
responseType: "code", // if you configured a android app in google dev console the value must be "code"
redirectUrl: "com.companyname.appname:/" // package name from google dev console
},
ios: {
appId: environment.oauthAppId.google.ios,
responseType: "code", // if you configured a ios app in google dev console the value must be "code"
redirectUrl: "com.companyname.appname:/" // Bundle ID from google dev console
}
}).then(resourceUrlResponse => {
// do sth e.g. check with your backend
}).catch(reason => {
console.error("Google OAuth rejected", reason);
});
}
```
#### Android
See [Android Default Config](#android-default-config)
#### iOS
See [iOS Default Config](#ios-default-config)
### Facebook
#### PWA
```typescript
import {GenericOAuth2} from "@capacitor-community/generic-oauth2";
facebookLogin()
{
let fbApiVersion = "2.11";
GenericOAuth2.authenticate({
appId: "YOUR_FACEBOOK_APP_ID",
authorizationBaseUrl: "https://www.facebook.com/v" + fbApiVersion + "/dialog/oauth",
resourceUrl: "https://graph.facebook.com/v" + fbApiVersion + "/me",
web: {
responseType: "token",
redirectUrl: "http://localhost:4200",
windowOptions: "height=600,left=0,top=0"
},
android: {
customHandlerClass: "com.companyname.appname.YourAndroidFacebookOAuth2Handler",
},
ios: {
customHandlerClass: "App.YourIOsFacebookOAuth2Handler",
}
}).then(resourceUrlResponse => {
// do sth e.g. check with your backend
}).catch(reason => {
console.error("FB OAuth rejected", reason);
});
}
```
**Android and iOS**
Since October 2018 Strict Mode for Redirect Urls is always on.
> Use Strict Mode for Redirect URIs
> Only allow redirects that use the Facebook SDK or that exactly match the Valid OAuth Redirect URIs. Strongly
> recommended.
Before that it was able to use `fb:/authorize` in a Android or iOS app and get the accessToken.
Unfortunately now we have to use the SDK for Facebook Login.
I don't want to have a dependency to facebook for users, who don't need Facebook OAuth.
To address this problem I created a integration with custom code in your app `customHandlerClass`
#### Android
See https://developers.facebook.com/docs/facebook-login/android/ for more background on how to configure Facebook in
your Android app.
1. Add `implementation 'com.facebook.android:facebook-login:4.36.0'` to `android/app/build.gradle` as dependency.
2. Add to `string.xml`
```xml
fb
```
3. Add to `AndroidManifest.xml`
```xml
```
4. Create a custom handler class
```java
package com.companyname.appname;
import android.app.Activity;
import com.companyname.appname.MainActivity;
import com.facebook.AccessToken;
import com.facebook.FacebookCallback;
import com.facebook.FacebookException;
import com.facebook.login.DefaultAudience;
import com.facebook.login.LoginBehavior;
import com.facebook.login.LoginManager;
import com.facebook.login.LoginResult;
import com.getcapacitor.PluginCall;
import com.getcapacitor.community.genericoauth2.handler.AccessTokenCallback;
import com.getcapacitor.community.genericoauth2.handler.OAuth2CustomHandler;
import java.util.Collections;
public class YourAndroidFacebookOAuth2Handler implements OAuth2CustomHandler {
@Override
public void getAccessToken(
Activity activity,
PluginCall pluginCall,
final AccessTokenCallback callback
) {
AccessToken accessToken = AccessToken.getCurrentAccessToken();
if (AccessToken.isCurrentAccessTokenActive()) {
callback.onSuccess(accessToken.getToken());
} else {
LoginManager l = LoginManager.getInstance();
l.logInWithReadPermissions(
activity,
Collections.singletonList("public_profile")
);
l.setLoginBehavior(LoginBehavior.WEB_ONLY);
l.setDefaultAudience(DefaultAudience.NONE);
LoginManager
.getInstance()
.registerCallback(
((MainActivity) activity).getCallbackManager(),
new FacebookCallback() {
@Override
public void onSuccess(LoginResult loginResult) {
callback.onSuccess(loginResult.getAccessToken().getToken());
}
@Override
public void onCancel() {
callback.onCancel();
}
@Override
public void onError(FacebookException error) {
callback.onCancel();
}
}
);
}
}
@Override
public boolean logout(Activity activity, PluginCall pluginCall) {
LoginManager.getInstance().logOut();
return true;
}
}
```
5. Change your MainActivity like
```java
public class MainActivity extends BridgeActivity {
private CallbackManager callbackManager;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Initialize Facebook SDK
FacebookSdk.sdkInitialize(this.getApplicationContext());
callbackManager = CallbackManager.Factory.create();
}
@Override
protected void onActivityResult(
int requestCode,
int resultCode,
Intent data
) {
super.onActivityResult(requestCode, resultCode, data);
if (callbackManager.onActivityResult(requestCode, resultCode, data)) {
return;
}
}
public CallbackManager getCallbackManager() {
return callbackManager;
}
}
```
**iOS**
See https://developers.facebook.com/docs/swift/getting-started and https://developers.facebook.com/docs/swift/login
1. Add Facebook pods to `ios/App/Podfile` and run `pod install` afterwards
```
platform :ios, '13.0'
use_frameworks!
# workaround to avoid Xcode caching of Pods that requires
# Product -> Clean Build Folder after new Cordova plugins installed
# Requires CocoaPods 1.6 or newer
install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCommunityGenericOauth2', :path => '../../node_modules/@capacitor-community/generic-oauth2'
# core plugins
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
pod 'CapacitorDevice', :path => '../../node_modules/@capacitor/device'
pod 'CapacitorKeyboard', :path => '../../node_modules/@capacitor/keyboard'
pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
end
target 'App' do
capacitor_pods
# Add your Pods here
pod 'FacebookCore'
pod 'FacebookLogin'
end
```
2. Add some Facebook configs to your `Info.plist`
```xml
CFBundleURLTypes
CFBundleURLSchemes
fb{your-app-id}
FacebookAppID
{your-app-id}
FacebookDisplayName
{your-app-name}
LSApplicationQueriesSchemes
fbapi
fb-messenger-share-api
fbauth2
fbshareextension
```
3. Create a custom handler class
```swift
import Foundation
import FacebookCore
import FacebookLogin
import Capacitor
import CapacitorCommunityGenericOauth2
@objc class YourIOsFacebookOAuth2Handler: NSObject, OAuth2CustomHandler {
required override init() {
}
func getAccessToken(viewController: UIViewController, call: CAPPluginCall, success: @escaping (String) -> Void, cancelled: @escaping () -> Void, failure: @escaping (Error) -> Void) {
if let accessToken = AccessToken.current {
success(accessToken.tokenString)
} else {
DispatchQueue.main.async {
let loginManager = LoginManager()
// I only need the most basic permissions but others are available
loginManager.logIn(permissions: [ .publicProfile ], viewController: viewController) { result in
switch result {
case .success(_, _, let accessToken):
success(accessToken.tokenString)
case .failed(let error):
failure(error)
case .cancelled:
cancelled()
}
}
}
}
}
func logout(viewController: UIViewController, call: CAPPluginCall) -> Bool {
let loginManager = LoginManager()
loginManager.logOut()
return true
}
}
```
This handler will be automatically discovered up by the plugin and handles the login using the Facebook SDK.
See https://developers.facebook.com/docs/swift/login/#custom-login-button for details.
4. The users that have redirect problem after success grant add the following code to `ios/App/App/AppDelegate.swift`.
This code correctly delegate the FB redirect url to be managed by Facebook SDK.
```swift
import UIKit
import FacebookCore
import FacebookLogin
import Capacitor
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
// other methods
func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool {
// Called when the app was launched with a url. Feel free to add additional processing here,
// but if you want the App API to support tracking app url opens, make sure to keep this call
if let scheme = url.scheme, let host = url.host {
let appId: String = Settings.appID!
if scheme == "fb\(appId)" && host == "authorize" {
return ApplicationDelegate.shared.application(app, open: url, options: options)
}
}
return CAPBridge.handleOpenUrl(url, options)
}
// other methods
}
```
## Contribute
See [Contribution Guidelines](https://github.com/capacitor-community/generic-oauth2/blob/main/.github/CONTRIBUTING.md).
## License
MIT. See [LICENSE](https://github.com/capacitor-community/generic-oauth2/blob/main/LICENSE).
## Disclaimer
We have no business relation to Ionic.
================================================
FILE: android/.gitignore
================================================
/build
================================================
FILE: android/build.gradle
================================================
ext {
appAuthVersion = project.hasProperty('appAuthVersion') ? rootProject.ext.appAuthVersion : '0.9.1'
androidxBrowserVersion = project.hasProperty('androidxBrowserVersion') ? rootProject.ext.androidxBrowserVersion : '1.8.0'
junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
commonsIoVersion = project.hasProperty('commonsIoVersion') ? rootProject.ext.commonsIoVersion : '2.10.0'
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.0'
junit5Version = project.hasProperty('junit5Version') ? rootProject.ext.junit5Version : '5.7.2'
androidJunit5Version = project.hasProperty('androidJunit5Version') ? rootProject.ext.androidJunit5Version : '1.2.2'
androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.6.1'
}
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.7.2'
}
}
apply plugin: 'com.android.library'
android {
namespace "com.getcapacitor.community.genericoauth2"
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 35
defaultConfig {
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 35
versionCode 1
versionName "1.0"
// 1) Make sure to use the AndroidJUnitRunner, or a subclass of it. This requires a dependency on androidx.test:runner, too!
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// 2) Connect JUnit 5 to the runner
// testInstrumentationRunnerArgument("runnerBuilder", "de.mannodermaus.junit5.AndroidJUnit5Builder")
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
lintOptions {
abortOnError false
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
unitTestVariants.all {
it.mergedFlavor.manifestPlaceholders += [
appAuthRedirectScheme: "com.getcapacitor.community.genericoauth2app"
]
}
// testOptions {
// unitTests {
// all {
// include 'com.getcapacitor.community.genericoauth2'
// }
// }
// }
}
repositories {
google()
mavenCentral()
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':capacitor-android')
implementation "androidx.browser:browser:$androidxBrowserVersion"
implementation "net.openid:appauth:$appAuthVersion"
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
// 4) Jupiter API & Test Runner, if you don't have it already
testImplementation("org.junit.jupiter:junit-jupiter-params:${junit5Version}")
testImplementation("org.junit.jupiter:junit-jupiter-api:${junit5Version}") {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
testImplementation "commons-io:commons-io:$commonsIoVersion"
}
// ###############
// ### AppAuth ###
// ###############
android.defaultConfig.manifestPlaceholders = [
'appAuthRedirectScheme': 'com.getcapacitor.community.genericoauth2app'
]
================================================
FILE: android/gradle/wrapper/gradle-wrapper.properties
================================================
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
================================================
FILE: android/gradle.properties
================================================
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
================================================
FILE: android/gradlew
================================================
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
================================================
FILE: android/gradlew.bat
================================================
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
================================================
FILE: android/proguard-rules.pro
================================================
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
================================================
FILE: android/settings.gradle
================================================
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
================================================
FILE: android/src/main/AndroidManifest.xml
================================================
================================================
FILE: android/src/main/java/com/getcapacitor/community/genericoauth2/ConfigUtils.java
================================================
package com.getcapacitor.community.genericoauth2;
import com.getcapacitor.JSObject;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Random;
import org.json.JSONException;
import org.json.JSONObject;
public abstract class ConfigUtils {
public static String getParamString(JSObject data, String key) {
return getParam(String.class, data, key);
}
public static T getParam(Class clazz, JSObject data, String key) {
return getParam(clazz, data, key, null);
}
public static T getParam(Class clazz, JSObject data, String key, T defaultValue) {
String k = getDeepestKey(key);
if (k != null) {
try {
Object value = null;
JSONObject o = getDeepestObject(data, key);
// #109
if (o.has(k)) {
if (clazz.isAssignableFrom(String.class)) {
value = o.getString(k);
} else if (clazz.isAssignableFrom(Boolean.class)) {
value = o.optBoolean(k);
} else if (clazz.isAssignableFrom(Double.class)) {
value = o.getDouble(k);
} else if (clazz.isAssignableFrom(Integer.class)) {
value = o.getInt(k);
} else if (clazz.isAssignableFrom(Long.class)) {
value = o.getLong(k);
} else if (clazz.isAssignableFrom(Float.class)) {
Double doubleValue = o.getDouble(k);
value = doubleValue.floatValue();
} else if (clazz.isAssignableFrom(Integer.class)) {
value = o.getInt(k);
}
}
if (value == null) {
return defaultValue;
}
return (T) value;
} catch (Exception ignore) {}
}
return defaultValue;
}
public static Map getParamMap(JSObject data, String key) {
Map map = new HashMap<>();
String k = getDeepestKey(key);
if (k != null) {
try {
JSONObject o = getDeepestObject(data, key);
JSONObject jsonObject = o.getJSONObject(k);
Iterator keys = jsonObject.keys();
while (keys.hasNext()) {
String mapKey = keys.next();
if (mapKey != null && mapKey.trim().length() > 0) {
try {
String mapValue = jsonObject.getString(mapKey);
map.put(mapKey, mapValue);
} catch (JSONException ignore) {}
}
}
} catch (Exception ignore) {}
}
return map;
}
public static String getDeepestKey(String key) {
String[] parts = key.split("\\.");
if (parts.length > 0) {
return parts[parts.length - 1];
}
return null;
}
public static JSObject getDeepestObject(JSObject o, String key) {
// Split on periods
String[] parts = key.split("\\.");
// Search until the second to last part of the key
for (int i = 0; i < parts.length - 1; i++) {
String k = parts[i];
o = o.getJSObject(k);
}
return o;
}
public static T getOverwrittenAndroidParam(Class clazz, JSObject data, String key) {
T baseParam = getParam(clazz, data, key);
T androidParam = getParam(clazz, data, "android." + key);
if (androidParam != null) {
baseParam = androidParam;
}
return baseParam;
}
public static Map getOverwrittenAndroidParamMap(JSObject data, String key) {
Map baseParam = getParamMap(data, key);
Map androidParam = getParamMap(data, "android." + key);
Map mergedParam = new HashMap<>(baseParam);
mergedParam.putAll(androidParam);
return mergedParam;
}
public static String getRandomString(int len) {
char[] ch = {
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
'a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'i',
'j',
'k',
'l',
'm',
'n',
'o',
'p',
'q',
'r',
's',
't',
'u',
'v',
'w',
'x',
'y',
'z'
};
char[] c = new char[len];
Random random = new Random();
for (int i = 0; i < len; i++) {
c[i] = ch[random.nextInt(ch.length)];
}
return new String(c);
}
public static String trimToNull(String value) {
if (value != null && value.trim().length() == 0) {
return null;
}
return value;
}
}
================================================
FILE: android/src/main/java/com/getcapacitor/community/genericoauth2/GenericOAuth2Plugin.java
================================================
package com.getcapacitor.community.genericoauth2;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.net.Uri;
import android.os.AsyncTask;
import android.util.Log;
import androidx.activity.result.ActivityResult;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.ActivityCallback;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.community.genericoauth2.handler.AccessTokenCallback;
import com.getcapacitor.community.genericoauth2.handler.OAuth2CustomHandler;
import java.util.Map;
import net.openid.appauth.AuthState;
import net.openid.appauth.AuthorizationException;
import net.openid.appauth.AuthorizationRequest;
import net.openid.appauth.AuthorizationResponse;
import net.openid.appauth.AuthorizationService;
import net.openid.appauth.AuthorizationServiceConfiguration;
import net.openid.appauth.EndSessionRequest;
import net.openid.appauth.EndSessionResponse;
import net.openid.appauth.GrantTypeValues;
import net.openid.appauth.TokenRequest;
import net.openid.appauth.TokenResponse;
import org.json.JSONException;
@CapacitorPlugin(name = "GenericOAuth2")
public class GenericOAuth2Plugin extends Plugin {
private static final String PARAM_APP_ID = "appId";
private static final String PARAM_AUTHORIZATION_BASE_URL = "authorizationBaseUrl";
private static final String PARAM_RESPONSE_TYPE = "responseType";
private static final String PARAM_REDIRECT_URL = "redirectUrl";
private static final String PARAM_SCOPE = "scope";
private static final String PARAM_STATE = "state";
private static final String PARAM_ACCESS_TOKEN_ENDPOINT = "accessTokenEndpoint";
private static final String PARAM_PKCE_ENABLED = "pkceEnabled";
private static final String PARAM_RESOURCE_URL = "resourceUrl";
private static final String PARAM_ADDITIONAL_RESOURCE_HEADERS = "additionalResourceHeaders";
private static final String PARAM_ADDITIONAL_PARAMETERS = "additionalParameters";
private static final String PARAM_ANDROID_CUSTOM_HANDLER_CLASS = "android.customHandlerClass";
// Activity result handling
private static final String PARAM_ANDROID_HANDLE_RESULT_ON_NEW_INTENT = "android.handleResultOnNewIntent";
private static final String PARAM_ANDROID_HANDLE_RESULT_ON_ACTIVITY_RESULT = "android.handleResultOnActivityResult";
// Refresh token params
private static final String PARAM_REFRESH_TOKEN = "refreshToken";
// open id params
private static final String PARAM_DISPLAY = "display";
private static final String PARAM_LOGIN_HINT = "login_hint";
private static final String PARAM_PROMPT = "prompt";
private static final String PARAM_RESPONSE_MODE = "response_mode";
private static final String PARAM_LOGS_ENABLED = "logsEnabled";
private static final String PARAM_LOGOUT_URL = "logoutUrl";
private static final String PARAM_ID_TOKEN = "id_token";
private static final String USER_CANCELLED = "USER_CANCELLED";
private static final String ERR_PARAM_NO_APP_ID = "ERR_PARAM_NO_APP_ID";
private static final String ERR_PARAM_NO_AUTHORIZATION_BASE_URL = "ERR_PARAM_NO_AUTHORIZATION_BASE_URL";
private static final String ERR_PARAM_NO_REDIRECT_URL = "ERR_PARAM_NO_REDIRECT_URL";
private static final String ERR_PARAM_NO_RESPONSE_TYPE = "ERR_PARAM_NO_RESPONSE_TYPE";
private static final String ERR_PARAM_NO_ACCESS_TOKEN_ENDPOINT = "ERR_PARAM_NO_ACCESS_TOKEN_ENDPOINT";
private static final String ERR_PARAM_NO_REFRESH_TOKEN = "ERR_PARAM_NO_REFRESH_TOKEN";
private static final String ERR_AUTHORIZATION_FAILED = "ERR_AUTHORIZATION_FAILED";
private static final String ERR_NO_ACCESS_TOKEN = "ERR_NO_ACCESS_TOKEN";
private static final String ERR_ANDROID_NO_BROWSER = "ERR_ANDROID_NO_BROWSER";
private static final String ERR_ANDROID_RESULT_NULL = "ERR_ANDROID_NO_INTENT";
private static final String ERR_CUSTOM_HANDLER_LOGIN = "ERR_CUSTOM_HANDLER_LOGIN";
private static final String ERR_CUSTOM_HANDLER_LOGOUT = "ERR_CUSTOM_HANDLER_LOGOUT";
private static final String ERR_GENERAL = "ERR_GENERAL";
private static final String ERR_STATES_NOT_MATCH = "ERR_STATES_NOT_MATCH";
private static final String ERR_NO_AUTHORIZATION_CODE = "ERR_NO_AUTHORIZATION_CODE";
private OAuth2Options oauth2Options;
private AuthorizationService authService;
private AuthState authState;
private String callbackId;
public GenericOAuth2Plugin() {}
@PluginMethod
public void refreshToken(final PluginCall call) {
disposeAuthService();
OAuth2RefreshTokenOptions oAuth2RefreshTokenOptions = buildRefreshTokenOptions(call.getData());
if (oAuth2RefreshTokenOptions.getAppId() == null) {
call.reject(ERR_PARAM_NO_APP_ID);
return;
}
if (oAuth2RefreshTokenOptions.getAccessTokenEndpoint() == null) {
call.reject(ERR_PARAM_NO_ACCESS_TOKEN_ENDPOINT);
return;
}
if (oAuth2RefreshTokenOptions.getRefreshToken() == null) {
call.reject(ERR_PARAM_NO_REFRESH_TOKEN);
return;
}
this.authService = new AuthorizationService(getContext());
AuthorizationServiceConfiguration config = new AuthorizationServiceConfiguration(
Uri.parse(""),
Uri.parse(oAuth2RefreshTokenOptions.getAccessTokenEndpoint())
);
if (this.authState == null) {
this.authState = new AuthState(config);
}
TokenRequest tokenRequest = new TokenRequest.Builder(config, oAuth2RefreshTokenOptions.getAppId())
.setGrantType(GrantTypeValues.REFRESH_TOKEN)
.setScope(oAuth2RefreshTokenOptions.getScope())
.setRefreshToken(oAuth2RefreshTokenOptions.getRefreshToken())
.build();
this.authService.performTokenRequest(
tokenRequest,
(response1, ex) -> {
this.authState.update(response1, ex);
if (ex != null) {
String message = ex.error != null ? ex.error : ERR_GENERAL;
call.reject(message, String.valueOf(ex.code), ex);
} else {
if (response1 != null) {
try {
JSObject json = new JSObject(response1.jsonSerializeString());
call.resolve(json);
} catch (JSONException e) {
call.reject(ERR_GENERAL, e);
}
} else {
call.reject(ERR_NO_ACCESS_TOKEN);
}
}
}
);
}
@PluginMethod
public void authenticate(final PluginCall call) {
this.callbackId = call.getCallbackId();
disposeAuthService();
oauth2Options = buildAuthenticateOptions(call.getData());
if (oauth2Options.getCustomHandlerClass() != null) {
if (oauth2Options.isLogsEnabled()) {
Log.i(getLogTag(), "Entering custom handler: " + oauth2Options.getCustomHandlerClass().getClass().getName());
}
try {
Class handlerClass = (Class) Class.forName(oauth2Options.getCustomHandlerClass());
OAuth2CustomHandler handler = handlerClass.newInstance();
handler.getAccessToken(
getActivity(),
call,
new AccessTokenCallback() {
@Override
public void onSuccess(String accessToken) {
new ResourceUrlAsyncTask(call, oauth2Options, getLogTag(), null, null).execute(accessToken);
}
@Override
public void onCancel() {
call.reject(USER_CANCELLED);
}
@Override
public void onError(Exception error) {
call.reject(ERR_CUSTOM_HANDLER_LOGIN, error);
}
}
);
} catch (InstantiationException | IllegalAccessException | ClassNotFoundException e) {
call.reject(ERR_CUSTOM_HANDLER_LOGIN, e);
} catch (Exception e) {
call.reject(ERR_GENERAL, e);
}
} else {
// ###################################
// ### Validate required parameter ###
// ###################################
if (oauth2Options.getAppId() == null) {
call.reject(ERR_PARAM_NO_APP_ID);
return;
}
if (oauth2Options.getAuthorizationBaseUrl() == null) {
call.reject(ERR_PARAM_NO_AUTHORIZATION_BASE_URL);
return;
}
if (oauth2Options.getResponseType() == null) {
call.reject(ERR_PARAM_NO_RESPONSE_TYPE);
return;
}
if (oauth2Options.getRedirectUrl() == null) {
call.reject(ERR_PARAM_NO_REDIRECT_URL);
return;
}
// ### Configure
Uri authorizationUri = Uri.parse(oauth2Options.getAuthorizationBaseUrl());
Uri accessTokenUri;
if (oauth2Options.getAccessTokenEndpoint() != null) {
accessTokenUri = Uri.parse(oauth2Options.getAccessTokenEndpoint());
} else {
// appAuth does not allow to be the accessTokenUri empty although it is not used unit performTokenRequest
accessTokenUri = authorizationUri;
}
AuthorizationServiceConfiguration config = new AuthorizationServiceConfiguration(authorizationUri, accessTokenUri);
if (this.authState == null) {
this.authState = new AuthState(config);
}
AuthorizationRequest.Builder builder = new AuthorizationRequest.Builder(
config,
oauth2Options.getAppId(),
oauth2Options.getResponseType(),
Uri.parse(oauth2Options.getRedirectUrl())
);
// app auth always uses a state
if (oauth2Options.getState() != null) {
builder.setState(oauth2Options.getState());
}
builder.setScope(oauth2Options.getScope());
if (oauth2Options.isPkceEnabled()) {
builder.setCodeVerifier(oauth2Options.getPkceCodeVerifier());
} else {
builder.setCodeVerifier(null);
}
if (oauth2Options.getPrompt() != null) {
builder.setPrompt(oauth2Options.getPrompt());
}
if (oauth2Options.getLoginHint() != null) {
builder.setLoginHint(oauth2Options.getLoginHint());
}
if (oauth2Options.getResponseMode() != null) {
builder.setResponseMode(oauth2Options.getResponseMode());
}
if (oauth2Options.getDisplay() != null) {
builder.setDisplay(oauth2Options.getDisplay());
}
if (oauth2Options.getAdditionalParameters() != null) {
try {
builder.setAdditionalParameters(oauth2Options.getAdditionalParameters());
} catch (IllegalArgumentException e) {
// ignore all additional parameter on error
Log.e(getLogTag(), "Additional parameter error", e);
}
}
AuthorizationRequest req = builder.build();
this.authService = new AuthorizationService(getContext());
try {
Intent authIntent = this.authService.getAuthorizationRequestIntent(req);
this.bridge.saveCall(call);
startActivityForResult(call, authIntent, "handleIntentResult");
} catch (ActivityNotFoundException e) {
call.reject(ERR_ANDROID_NO_BROWSER, e);
} catch (Exception e) {
Log.e(getLogTag(), "Unexpected exception on open browser for authorization request!");
call.reject(ERR_GENERAL, e);
}
}
}
@PluginMethod
public void logout(final PluginCall call) {
String customHandlerClassname = ConfigUtils.getParam(String.class, call.getData(), PARAM_ANDROID_CUSTOM_HANDLER_CLASS);
if (customHandlerClassname != null && customHandlerClassname.length() > 0) {
try {
Class handlerClass = (Class) Class.forName(customHandlerClassname);
OAuth2CustomHandler handler = handlerClass.newInstance();
boolean successful = handler.logout(getActivity(), call);
if (successful) {
call.resolve();
} else {
call.reject(ERR_CUSTOM_HANDLER_LOGOUT);
}
} catch (InstantiationException | IllegalAccessException | ClassNotFoundException e) {
call.reject(ERR_CUSTOM_HANDLER_LOGOUT, e);
} catch (Exception e) {
call.reject(ERR_GENERAL, e);
}
} else {
String idToken = ConfigUtils.getParam(String.class, call.getData(), PARAM_ID_TOKEN);
if (idToken == null) {
this.disposeAuthService();
this.discardAuthState();
call.resolve();
return;
}
oauth2Options = buildAuthenticateOptions(call.getData());
Uri authorizationUri = Uri.parse(oauth2Options.getAuthorizationBaseUrl());
Uri accessTokenUri;
if (oauth2Options.getAccessTokenEndpoint() != null) {
accessTokenUri = Uri.parse(oauth2Options.getAccessTokenEndpoint());
} else {
// appAuth does not allow to be the accessTokenUri empty although it is not used unit performTokenRequest
accessTokenUri = authorizationUri;
}
Uri logoutUri = Uri.parse(oauth2Options.getLogoutUrl());
AuthorizationServiceConfiguration config = new AuthorizationServiceConfiguration(authorizationUri, accessTokenUri);
EndSessionRequest endSessionRequest = new EndSessionRequest.Builder(config)
.setIdTokenHint(idToken)
.setPostLogoutRedirectUri(logoutUri)
.build();
this.authService = new AuthorizationService(getContext());
try {
Intent endSessionIntent = authService.getEndSessionRequestIntent(endSessionRequest);
this.bridge.saveCall(call);
startActivityForResult(call, endSessionIntent, "handleEndSessionIntentResult");
} catch (ActivityNotFoundException e) {
call.reject(ERR_ANDROID_NO_BROWSER, e);
} catch (Exception e) {
Log.e(getLogTag(), "Unexpected exception on open browser for logout request!");
call.reject(ERR_GENERAL, e);
}
}
}
@Override
protected void handleOnNewIntent(Intent intent) {
// this is a experimental hook and only usable if the android system kills the app between
if (this.oauth2Options != null && this.oauth2Options.isHandleResultOnNewIntent()) {
// with this I have no way to check if this intent is for this plugin
PluginCall savedCall = this.bridge.getSavedCall(this.callbackId);
if (savedCall == null) {
return;
}
handleAuthorizationRequestActivity(intent, savedCall);
}
}
@ActivityCallback
private void handleIntentResult(PluginCall call, ActivityResult result) {
if (this.oauth2Options != null && this.oauth2Options.isHandleResultOnActivityResult()) {
if (result.getResultCode() == Activity.RESULT_CANCELED) {
call.reject(USER_CANCELLED);
} else {
handleAuthorizationRequestActivity(result.getData(), call);
}
}
}
@ActivityCallback
private void handleEndSessionIntentResult(PluginCall call, ActivityResult result) {
if (result.getResultCode() == Activity.RESULT_CANCELED) {
call.reject(USER_CANCELLED);
} else {
if (result.getData() != null) {
try {
EndSessionResponse resp = EndSessionResponse.fromIntent(result.getData());
JSObject json = new JSObject(resp.jsonSerializeString());
this.disposeAuthService();
this.discardAuthState();
call.resolve(json);
} catch (Exception e) {
Log.e(getLogTag(), "Unexpected exception on handling result for logout request!");
call.reject(ERR_GENERAL, e);
return;
}
}
}
}
void handleAuthorizationRequestActivity(Intent intent, PluginCall savedCall) {
// there are valid situation when the Intent is null, but
if (intent != null) {
AuthorizationResponse authorizationResponse;
AuthorizationException error;
try {
authorizationResponse = AuthorizationResponse.fromIntent(intent);
error = AuthorizationException.fromIntent(intent);
this.authState.update(authorizationResponse, error);
} catch (Exception e) {
savedCall.reject(ERR_GENERAL, e);
return;
}
if (error != null) {
if (error.code == AuthorizationException.GeneralErrors.USER_CANCELED_AUTH_FLOW.code) {
savedCall.reject(USER_CANCELLED);
} else if (error.code == AuthorizationException.AuthorizationRequestErrors.STATE_MISMATCH.code) {
if (oauth2Options.isLogsEnabled()) {
Log.i(getLogTag(), "State from web options: " + oauth2Options.getState());
if (authorizationResponse != null) {
Log.i(getLogTag(), "State returned from provider: " + authorizationResponse.state);
}
}
savedCall.reject(ERR_STATES_NOT_MATCH);
} else {
savedCall.reject(ERR_GENERAL, error);
}
return;
}
// this response may contain the authorizationCode but also idToken and accessToken depending on the flow chosen by responseType
if (authorizationResponse != null) {
if (oauth2Options.isLogsEnabled()) {
Log.i(getLogTag(), "Authorization response:\n" + authorizationResponse.jsonSerializeString());
}
// if there is a tokenEndpoint configured try to get the accessToken from it.
// it might be already in the authorizationResponse but tokenEndpoint might deliver other tokens.
if (oauth2Options.getAccessTokenEndpoint() != null) {
this.authService = new AuthorizationService(getContext());
TokenRequest tokenExchangeRequest;
try {
tokenExchangeRequest = authorizationResponse.createTokenExchangeRequest();
this.authService.performTokenRequest(
tokenExchangeRequest,
(accessTokenResponse, exception) -> {
authState.update(accessTokenResponse, exception);
if (exception != null) {
savedCall.reject(ERR_AUTHORIZATION_FAILED, String.valueOf(exception.code), exception);
} else {
if (accessTokenResponse != null) {
if (oauth2Options.isLogsEnabled()) {
Log.i(getLogTag(), "Access token response:\n" + accessTokenResponse.jsonSerializeString());
}
authState.performActionWithFreshTokens(
authService,
(accessToken, idToken, ex1) -> {
AsyncTask asyncTask = new ResourceUrlAsyncTask(
savedCall,
oauth2Options,
getLogTag(),
authorizationResponse,
accessTokenResponse
);
asyncTask.execute(accessToken);
}
);
} else {
resolveAuthorizationResponse(savedCall, authorizationResponse);
}
}
}
);
} catch (Exception e) {
savedCall.reject(ERR_NO_AUTHORIZATION_CODE, e);
}
} else {
resolveAuthorizationResponse(savedCall, authorizationResponse);
}
} else {
savedCall.reject(ERR_NO_AUTHORIZATION_CODE);
}
} else {
// the intent is null because the provider send the redirect to the server, which would be valid
// the intent is null because the plugin user configured sth wrong incl.
// the provider does not support redirecting to a android app, which would be invalid
savedCall.reject(ERR_ANDROID_RESULT_NULL);
}
}
private void resolveAuthorizationResponse(PluginCall savedCall, AuthorizationResponse authorizationResponse) {
JSObject json = new JSObject();
OAuth2Utils.assignResponses(json, null, authorizationResponse, null);
savedCall.resolve(json);
}
OAuth2Options buildAuthenticateOptions(JSObject callData) {
OAuth2Options o = new OAuth2Options();
// required
o.setAppId(ConfigUtils.trimToNull(ConfigUtils.getOverwrittenAndroidParam(String.class, callData, PARAM_APP_ID)));
o.setAuthorizationBaseUrl(
ConfigUtils.trimToNull(ConfigUtils.getOverwrittenAndroidParam(String.class, callData, PARAM_AUTHORIZATION_BASE_URL))
);
o.setResponseType(ConfigUtils.trimToNull(ConfigUtils.getOverwrittenAndroidParam(String.class, callData, PARAM_RESPONSE_TYPE)));
o.setRedirectUrl(ConfigUtils.trimToNull(ConfigUtils.getOverwrittenAndroidParam(String.class, callData, PARAM_REDIRECT_URL)));
// optional
Boolean logsEnabled = ConfigUtils.getOverwrittenAndroidParam(Boolean.class, callData, PARAM_LOGS_ENABLED);
o.setLogsEnabled(logsEnabled != null && logsEnabled);
o.setResourceUrl(ConfigUtils.trimToNull(ConfigUtils.getOverwrittenAndroidParam(String.class, callData, PARAM_RESOURCE_URL)));
o.setAccessTokenEndpoint(
ConfigUtils.trimToNull(ConfigUtils.getOverwrittenAndroidParam(String.class, callData, PARAM_ACCESS_TOKEN_ENDPOINT))
);
Boolean pkceEnabledObj = ConfigUtils.getOverwrittenAndroidParam(Boolean.class, callData, PARAM_PKCE_ENABLED);
o.setPkceEnabled(pkceEnabledObj != null && pkceEnabledObj);
if (o.isPkceEnabled()) {
o.setPkceCodeVerifier(ConfigUtils.getRandomString(64));
}
o.setScope(ConfigUtils.trimToNull(ConfigUtils.getOverwrittenAndroidParam(String.class, callData, PARAM_SCOPE)));
o.setState(ConfigUtils.trimToNull(ConfigUtils.getOverwrittenAndroidParam(String.class, callData, PARAM_STATE)));
if (o.getState() == null) {
o.setState(ConfigUtils.getRandomString(20));
}
Map additionalParameters = ConfigUtils.getOverwrittenAndroidParamMap(callData, PARAM_ADDITIONAL_PARAMETERS);
if (!additionalParameters.isEmpty()) {
for (Map.Entry entry : additionalParameters.entrySet()) {
String key = entry.getKey();
if (PARAM_DISPLAY.equals(key)) {
o.setDisplay(entry.getValue());
} else if (PARAM_LOGIN_HINT.equals(key)) {
o.setLoginHint(entry.getValue());
} else if (PARAM_PROMPT.equals(key)) {
o.setPrompt(entry.getValue());
} else if (PARAM_RESPONSE_MODE.equals(key)) {
o.setResponseMode(entry.getValue());
} else {
o.addAdditionalParameter(key, entry.getValue());
}
}
}
o.setAdditionalResourceHeaders(ConfigUtils.getOverwrittenAndroidParamMap(callData, PARAM_ADDITIONAL_RESOURCE_HEADERS));
// android only
o.setCustomHandlerClass(ConfigUtils.trimToNull(ConfigUtils.getParamString(callData, PARAM_ANDROID_CUSTOM_HANDLER_CLASS)));
o.setHandleResultOnNewIntent(ConfigUtils.getParam(Boolean.class, callData, PARAM_ANDROID_HANDLE_RESULT_ON_NEW_INTENT, false));
o.setHandleResultOnActivityResult(
ConfigUtils.getParam(Boolean.class, callData, PARAM_ANDROID_HANDLE_RESULT_ON_ACTIVITY_RESULT, false)
);
if (!o.isHandleResultOnNewIntent() && !o.isHandleResultOnActivityResult()) {
o.setHandleResultOnActivityResult(true);
}
return o;
}
OAuth2RefreshTokenOptions buildRefreshTokenOptions(JSObject callData) {
OAuth2RefreshTokenOptions o = new OAuth2RefreshTokenOptions();
o.setAppId(ConfigUtils.trimToNull(ConfigUtils.getOverwrittenAndroidParam(String.class, callData, PARAM_APP_ID)));
o.setAccessTokenEndpoint(
ConfigUtils.trimToNull(ConfigUtils.getOverwrittenAndroidParam(String.class, callData, PARAM_ACCESS_TOKEN_ENDPOINT))
);
o.setScope(ConfigUtils.trimToNull(ConfigUtils.getOverwrittenAndroidParam(String.class, callData, PARAM_SCOPE)));
o.setRefreshToken(ConfigUtils.trimToNull(ConfigUtils.getOverwrittenAndroidParam(String.class, callData, PARAM_REFRESH_TOKEN)));
return o;
}
@Override
protected void handleOnStop() {
super.handleOnStop();
disposeAuthService();
}
private void disposeAuthService() {
if (authService != null) {
authService.dispose();
authService = null;
}
}
private void discardAuthState() {
if (this.authState != null) {
this.authState = null;
}
}
}
================================================
FILE: android/src/main/java/com/getcapacitor/community/genericoauth2/OAuth2Options.java
================================================
package com.getcapacitor.community.genericoauth2;
import java.util.HashMap;
import java.util.Map;
public class OAuth2Options {
// required
private String appId;
private String authorizationBaseUrl;
private String responseType;
private String redirectUrl;
private String scope;
private String state;
private String accessTokenEndpoint;
private String resourceUrl;
private Map additionalResourceHeaders;
private boolean pkceEnabled;
private boolean logsEnabled;
private String pkceCodeVerifier;
private Map additionalParameters;
private String customHandlerClass;
// Activity result handling
private boolean handleResultOnNewIntent;
private boolean handleResultOnActivityResult = true;
private String display;
private String loginHint;
private String prompt;
private String responseMode;
private String logoutUrl;
public String getAppId() {
return appId;
}
public void setAppId(String appId) {
this.appId = appId;
}
public String getAuthorizationBaseUrl() {
return authorizationBaseUrl;
}
public void setAuthorizationBaseUrl(String authorizationBaseUrl) {
this.authorizationBaseUrl = authorizationBaseUrl;
}
public String getAccessTokenEndpoint() {
return accessTokenEndpoint;
}
public void setAccessTokenEndpoint(String accessTokenEndpoint) {
this.accessTokenEndpoint = accessTokenEndpoint;
}
public String getResourceUrl() {
return resourceUrl;
}
public void setResourceUrl(String resourceUrl) {
this.resourceUrl = resourceUrl;
}
public boolean isLogsEnabled() {
return logsEnabled;
}
public void setLogsEnabled(boolean logsEnabled) {
this.logsEnabled = logsEnabled;
}
public String getResponseType() {
return responseType;
}
public void setResponseType(String responseType) {
this.responseType = responseType;
}
public String getScope() {
return scope;
}
public void setScope(String scope) {
this.scope = scope;
}
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
public String getRedirectUrl() {
return redirectUrl;
}
public void setRedirectUrl(String redirectUrl) {
this.redirectUrl = redirectUrl;
}
public String getCustomHandlerClass() {
return customHandlerClass;
}
public void setCustomHandlerClass(String customHandlerClass) {
this.customHandlerClass = customHandlerClass;
}
public boolean isPkceEnabled() {
return pkceEnabled;
}
public void setPkceEnabled(boolean pkceEnabled) {
this.pkceEnabled = pkceEnabled;
}
public String getPkceCodeVerifier() {
return pkceCodeVerifier;
}
public void setPkceCodeVerifier(String pkceCodeVerifier) {
this.pkceCodeVerifier = pkceCodeVerifier;
}
public Map getAdditionalParameters() {
return additionalParameters;
}
public void setAdditionalParameters(Map additionalParameters) {
this.additionalParameters = additionalParameters;
}
public void addAdditionalParameter(String key, String value) {
if (key != null && value != null) {
if (this.additionalParameters == null) {
this.additionalParameters = new HashMap<>();
}
this.additionalParameters.put(key, value);
}
}
public String getDisplay() {
return display;
}
public void setDisplay(String display) {
this.display = display;
}
public String getLoginHint() {
return loginHint;
}
public void setLoginHint(String loginHint) {
this.loginHint = loginHint;
}
public String getPrompt() {
return prompt;
}
public void setPrompt(String prompt) {
this.prompt = prompt;
}
public String getResponseMode() {
return responseMode;
}
public void setResponseMode(String responseMode) {
this.responseMode = responseMode;
}
public boolean isHandleResultOnNewIntent() {
return handleResultOnNewIntent;
}
public void setHandleResultOnNewIntent(boolean handleResultOnNewIntent) {
this.handleResultOnNewIntent = handleResultOnNewIntent;
}
public boolean isHandleResultOnActivityResult() {
return handleResultOnActivityResult;
}
public void setHandleResultOnActivityResult(boolean handleResultOnActivityResult) {
this.handleResultOnActivityResult = handleResultOnActivityResult;
}
public Map getAdditionalResourceHeaders() {
return additionalResourceHeaders;
}
public void setAdditionalResourceHeaders(Map additionalResourceHeaders) {
this.additionalResourceHeaders = additionalResourceHeaders;
}
public void addAdditionalResourceHeader(String key, String value) {
if (key != null && value != null) {
if (this.additionalResourceHeaders == null) {
this.additionalResourceHeaders = new HashMap<>();
}
this.additionalResourceHeaders.put(key, value);
}
}
public String getLogoutUrl() {
return logoutUrl;
}
}
================================================
FILE: android/src/main/java/com/getcapacitor/community/genericoauth2/OAuth2RefreshTokenOptions.java
================================================
package com.getcapacitor.community.genericoauth2;
public class OAuth2RefreshTokenOptions {
private String appId;
private String accessTokenEndpoint;
private String refreshToken;
private String scope;
public String getAppId() {
return appId;
}
public void setAppId(String appId) {
this.appId = appId;
}
public String getAccessTokenEndpoint() {
return accessTokenEndpoint;
}
public void setAccessTokenEndpoint(String accessTokenEndpoint) {
this.accessTokenEndpoint = accessTokenEndpoint;
}
public String getRefreshToken() {
return refreshToken;
}
public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
public String getScope() {
return scope;
}
public void setScope(String scope) {
this.scope = scope;
}
}
================================================
FILE: android/src/main/java/com/getcapacitor/community/genericoauth2/OAuth2Utils.java
================================================
package com.getcapacitor.community.genericoauth2;
import com.getcapacitor.JSObject;
import net.openid.appauth.AuthorizationResponse;
import net.openid.appauth.TokenResponse;
public abstract class OAuth2Utils {
public static void assignResponses(
JSObject resp,
String accessToken,
AuthorizationResponse authorizationResponse,
TokenResponse accessTokenResponse
) {
// #154
if (authorizationResponse != null) {
resp.put("authorization_response", authorizationResponse.jsonSerialize());
}
if (accessTokenResponse != null) {
resp.put("access_token_response", accessTokenResponse.jsonSerialize());
}
if (accessToken != null) {
resp.put("access_token", accessToken);
}
}
}
================================================
FILE: android/src/main/java/com/getcapacitor/community/genericoauth2/ResourceCallResult.java
================================================
package com.getcapacitor.community.genericoauth2;
import com.getcapacitor.JSObject;
public class ResourceCallResult {
private boolean error;
private String errorMsg;
private JSObject response;
public boolean isError() {
return error;
}
public void setError(boolean error) {
this.error = error;
}
public JSObject getResponse() {
return response;
}
public void setResponse(JSObject response) {
this.response = response;
}
public String getErrorMsg() {
return errorMsg;
}
public void setErrorMsg(String errorMsg) {
this.errorMsg = errorMsg;
}
}
================================================
FILE: android/src/main/java/com/getcapacitor/community/genericoauth2/ResourceUrlAsyncTask.java
================================================
package com.getcapacitor.community.genericoauth2;
import android.os.AsyncTask;
import android.util.Log;
import com.getcapacitor.JSObject;
import com.getcapacitor.PluginCall;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Map;
import net.openid.appauth.AuthorizationResponse;
import net.openid.appauth.TokenResponse;
import org.json.JSONException;
public class ResourceUrlAsyncTask extends AsyncTask {
private static final String ERR_GENERAL = "ERR_GENERAL";
private static final String ERR_NO_ACCESS_TOKEN = "ERR_NO_ACCESS_TOKEN";
private static final String MSG_RETURNED_TO_JS = "Returned to JS:\n";
private final PluginCall pluginCall;
private final OAuth2Options options;
private final String logTag;
private final AuthorizationResponse authorizationResponse;
private final TokenResponse accessTokenResponse;
public ResourceUrlAsyncTask(
PluginCall pluginCall,
OAuth2Options options,
String logTag,
AuthorizationResponse authorizationResponse,
TokenResponse accessTokenResponse
) {
this.pluginCall = pluginCall;
this.options = options;
this.logTag = logTag;
this.authorizationResponse = authorizationResponse;
this.accessTokenResponse = accessTokenResponse;
}
@Override
protected ResourceCallResult doInBackground(String... tokens) {
ResourceCallResult result = new ResourceCallResult();
String resourceUrl = options.getResourceUrl();
String accessToken = tokens[0];
if (resourceUrl != null) {
Log.i(logTag, "Resource url: GET " + resourceUrl);
if (accessToken != null) {
Log.i(logTag, "Access token:\n" + accessToken);
try {
URL url = new URL(resourceUrl);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.addRequestProperty("Authorization", String.format("Bearer %s", accessToken));
// additional headers
if (options.getAdditionalResourceHeaders() != null) {
for (Map.Entry entry : options.getAdditionalResourceHeaders().entrySet()) {
conn.addRequestProperty(entry.getKey(), entry.getValue());
}
}
InputStream is = null;
try {
if (
conn.getResponseCode() >= HttpURLConnection.HTTP_OK &&
conn.getResponseCode() < HttpURLConnection.HTTP_MULT_CHOICE
) {
is = conn.getInputStream();
} else {
is = conn.getErrorStream();
result.setError(true);
}
String resourceResponseBody = readInputStream(is);
if (!result.isError()) {
JSObject resultJson = new JSObject(resourceResponseBody);
if (options.isLogsEnabled()) {
Log.i(logTag, "Resource response:\n" + resourceResponseBody);
}
OAuth2Utils.assignResponses(resultJson, accessToken, this.authorizationResponse, this.accessTokenResponse);
if (options.isLogsEnabled()) {
Log.i(logTag, MSG_RETURNED_TO_JS + resultJson);
}
result.setResponse(resultJson);
} else {
result.setErrorMsg(resourceResponseBody);
}
} catch (IOException e) {
Log.e(logTag, "", e);
} catch (JSONException e) {
Log.e(logTag, "Resource response no valid json.", e);
} finally {
conn.disconnect();
if (is != null) {
is.close();
}
}
} catch (MalformedURLException e) {
Log.e(logTag, "Invalid resource url '" + resourceUrl + "'", e);
} catch (IOException e) {
Log.e(logTag, "Unexpected error", e);
}
} else {
if (options.isLogsEnabled()) {
Log.i(
logTag,
"No accessToken was provided although you configured a resourceUrl. Remove the resourceUrl from the config."
);
}
pluginCall.reject(ERR_NO_ACCESS_TOKEN);
}
} else {
JSObject json = new JSObject();
OAuth2Utils.assignResponses(json, accessToken, this.authorizationResponse, this.accessTokenResponse);
if (options.isLogsEnabled()) {
Log.i(logTag, MSG_RETURNED_TO_JS + json);
}
result.setResponse(json);
}
return result;
}
@Override
protected void onPostExecute(ResourceCallResult response) {
if (response != null) {
if (!response.isError()) {
pluginCall.resolve(response.getResponse());
} else {
Log.e(logTag, response.getErrorMsg());
pluginCall.reject(ERR_GENERAL, response.getErrorMsg());
}
} else {
pluginCall.reject(ERR_GENERAL);
}
}
private static String readInputStream(InputStream in) throws IOException {
try (BufferedReader br = new BufferedReader(new InputStreamReader(in))) {
char[] buffer = new char[1024];
StringBuilder sb = new StringBuilder();
int readCount;
while ((readCount = br.read(buffer)) != -1) {
sb.append(buffer, 0, readCount);
}
return sb.toString();
}
}
}
================================================
FILE: android/src/main/java/com/getcapacitor/community/genericoauth2/handler/AccessTokenCallback.java
================================================
package com.getcapacitor.community.genericoauth2.handler;
public interface AccessTokenCallback {
void onSuccess(String accessToken);
void onCancel();
void onError(Exception error);
}
================================================
FILE: android/src/main/java/com/getcapacitor/community/genericoauth2/handler/OAuth2CustomHandler.java
================================================
package com.getcapacitor.community.genericoauth2.handler;
import android.app.Activity;
import com.getcapacitor.PluginCall;
public interface OAuth2CustomHandler {
void getAccessToken(Activity activity, PluginCall pluginCall, final AccessTokenCallback callback);
boolean logout(Activity activity, PluginCall pluginCall);
}
================================================
FILE: android/src/main/res/.gitkeep
================================================
================================================
FILE: android/src/test/java/com/getcapacitor/community/genericoauth2/ConfigUtilsTest.java
================================================
package com.getcapacitor.community.genericoauth2;
import android.util.Log;
import com.getcapacitor.JSObject;
import java.util.Map;
import java.util.stream.Stream;
import org.json.JSONException;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
public class ConfigUtilsTest {
private static final String BASE_JSON =
"{\n" +
" \"doubleValue\": 123.4567,\n" +
" \"floatValue\": 123.4,\n" +
" \"intValue\": 1,\n" +
" \"stringValue\": \"string\",\n" +
" \"booleanValue\": true,\n" +
" \"accessTokenEndpoint\": \"https://byteowls.com\",\n" +
" \"first\": {\n" +
" \"second\": {\n" +
" \"third\": {\n" +
" \"doubleValue\": 5.4,\n" +
" \"floatValue\": 5.9,\n" +
" \"intValue\": 2,\n" +
" \"stringValue\": \"stringDeep\",\n" +
" \"booleanValue\": false\n" +
" }\n" +
" }\n" +
" },\n" +
" \"map\": {\n" +
" \"key1\": \"value1\",\n" +
" \"key2\": \"value2\",\n" +
" \"inMapNullable\": \"notEmpty\"\n" +
" },\n" +
" \"android\": {\n" +
" \"stringValue\": \"stringAndroid\",\n" +
" \"accessTokenEndpoint\": \"\",\n" +
" \"map\": {\n" +
" \"key1\": \"value1Android\",\n" +
" \"key3\": \"value3Android\",\n" +
" \"inMapNullable\": \"\"\n" +
" }\n" +
" },\n" +
" \"empty\": \"\",\n" +
" \"blank\": \" \"\n" +
"}";
private JSObject jsObject;
@BeforeEach
public void setUp() {
try {
this.jsObject = new JSObject(BASE_JSON);
} catch (Exception e) {
Log.e("OAuth2", "", e);
}
}
@Test
public void getParamString() {
String stringValue = ConfigUtils.getParamString(jsObject, "stringValue");
Assertions.assertNotNull(stringValue);
Assertions.assertEquals("string", stringValue);
}
@Test
public void getParam() {
String stringValue = ConfigUtils.getParam(String.class, jsObject, "stringValue");
Assertions.assertNotNull(stringValue);
Assertions.assertEquals("string", stringValue);
Double doubleValue = ConfigUtils.getParam(Double.class, jsObject, "doubleValue");
Assertions.assertNotNull(doubleValue);
}
@Test
public void getParamMap() {
Map map = ConfigUtils.getParamMap(jsObject, "map");
Assertions.assertNotNull(map);
Assertions.assertEquals("value1", map.get("key1"));
}
@Test
public void getDeepestKey() {
String deepestKey = ConfigUtils.getDeepestKey("com.example.deep");
Assertions.assertEquals("deep", deepestKey);
deepestKey = ConfigUtils.getDeepestKey("com");
Assertions.assertEquals("com", deepestKey);
}
@Test
public void getDeepestObject() {
JSObject object = ConfigUtils.getDeepestObject(jsObject, "first.second.third");
Assertions.assertNotNull(object.getJSObject("third"));
}
@Test
public void getOverwrittenAndroidParam() {
String overwrittenString = ConfigUtils.getOverwrittenAndroidParam(String.class, jsObject, "stringValue");
Assertions.assertEquals("stringAndroid", overwrittenString);
int intValue = ConfigUtils.getOverwrittenAndroidParam(Integer.class, jsObject, "intValue");
Assertions.assertEquals(1, intValue);
}
@Test
public void getOverwrittenAndroidParamMap() {
Map map = ConfigUtils.getOverwrittenAndroidParamMap(jsObject, "map");
Assertions.assertNotNull(map);
Assertions.assertEquals("value1Android", map.get("key1"));
Assertions.assertEquals("value2", map.get("key2"));
Assertions.assertEquals("value3Android", map.get("key3"));
}
@Test
public void overwriteWithEmpty() {
String accessTokenEndpoint = "accessTokenEndpoint";
Assertions.assertNotNull(ConfigUtils.getParamString(jsObject, accessTokenEndpoint));
Assertions.assertEquals("", ConfigUtils.getOverwrittenAndroidParam(String.class, jsObject, accessTokenEndpoint));
String inMapNullable = "inMapNullable";
Map paramMap = ConfigUtils.getParamMap(jsObject, "map");
Assertions.assertNotNull(paramMap.get(inMapNullable));
Map androidParamMap = ConfigUtils.getOverwrittenAndroidParamMap(jsObject, "map");
Assertions.assertEquals("", androidParamMap.get(inMapNullable));
}
@ParameterizedTest
@MethodSource("getBooleanArguments")
public void getOverwrittenBoolean(String json, String key, Boolean expected) throws JSONException {
JSObject jsObject = new JSObject(json);
Boolean actual = ConfigUtils.getOverwrittenAndroidParam(Boolean.class, jsObject, key);
if (expected == null) {
Assertions.assertNull(actual);
} else {
Assertions.assertEquals(expected, actual);
}
}
private static Stream getBooleanArguments() {
return Stream.of(
Arguments.of("{ \"pkceEnabled\": true, \"android\":{\"pkceEnabled\": false}}", "pkceEnabled", false),
Arguments.of("{ \"pkceEnabled\": true}", "pkceEnabled", true),
Arguments.of("{ \"pkceEnabled\": true}", "android.pkceEnabled", null),
Arguments.of("{ \"pkceEnabled\": true, \"ios\":{\"pkceEnabled\": false}}", "pkceEnabled", true)
);
}
@Test
public void getRandomString() {
String randomString = ConfigUtils.getRandomString(8);
Assertions.assertNotNull(randomString);
Assertions.assertEquals(8, randomString.length());
}
@Test
public void empty() {
// make sure the empty value stays empty
String emptyValue = ConfigUtils.getParamString(jsObject, "empty");
Assertions.assertEquals(0, emptyValue.length());
}
@Test
public void blank() {
// make sure the blank value stays blank
String blankValue = ConfigUtils.getParamString(jsObject, "blank");
Assertions.assertEquals(" ", blankValue);
}
@Test
public void trimToNull() {
Assertions.assertNull(ConfigUtils.trimToNull(" "));
Assertions.assertNull(ConfigUtils.trimToNull(" "));
Assertions.assertNull(ConfigUtils.trimToNull(""));
Assertions.assertEquals("a", ConfigUtils.trimToNull("a"));
}
}
================================================
FILE: android/src/test/java/com/getcapacitor/community/genericoauth2/GenericOAuth2PluginTest.java
================================================
package com.getcapacitor.community.genericoauth2;
import android.util.Log;
import com.getcapacitor.JSObject;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class GenericOAuth2PluginTest {
public static final String CLIENT_ID_ANDROID = "CLIENT_ID_ANDROID";
private GenericOAuth2Plugin plugin;
@BeforeEach
public void setup() {
plugin = new GenericOAuth2Plugin();
}
@Test
public void allBooleanValues() {
JSObject jsObject = loadJson(
"{\n" +
" \"appId\": \"CLIENT_ID\",\n" +
" \"authorizationBaseUrl\": \"https://accounts.google.com/o/oauth2/auth\",\n" +
" \"accessTokenEndpoint\": \"https://www.googleapis.com/oauth2/v4/token\",\n" +
" \"scope\": \"email profile\",\n" +
" \"pkceEnabled\": true,\n" +
" \"logsEnabled\": true,\n" +
" \"resourceUrl\": \"https://www.googleapis.com/userinfo/v2/me\",\n" +
" \"web\": {\n" +
" \"redirectUrl\": \"http://localhost:4200\",\n" +
" \"windowOptions\": \"height=600,left=0,top=0\"\n" +
" },\n" +
" \"android\": {\n" +
" \"appId\": \"" +
CLIENT_ID_ANDROID +
"\",\n" +
" \"redirectUrl\": \"com.company.project:/\",\n" +
" \"handleResultMethod\": \"TEST\",\n" +
" \"logsEnabled\": false,\n" +
" \"handleResultOnNewIntent\": true,\n" +
" \"handleResultOnActivityResult\": false,\n" +
" \"responseType\": \"TOKEN\"\n" +
" },\n" +
" \"ios\": {\n" +
" \"appId\": \"CLIENT_ID_IOS\",\n" +
" \"responseType\": \"code\",\n" +
" \"redirectUrl\": \"com.company.project:/\"\n" +
" }\n" +
"}\n"
);
OAuth2Options options = plugin.buildAuthenticateOptions(jsObject);
Assertions.assertNotNull(options);
Assertions.assertTrue(options.isPkceEnabled());
Assertions.assertFalse(options.isLogsEnabled());
Assertions.assertTrue(options.isHandleResultOnNewIntent());
Assertions.assertFalse(options.isHandleResultOnActivityResult());
}
@Test
public void responseTypeToken() {
JSObject jsObject = loadJson(
"{\n" +
" \"appId\": \"CLIENT_ID\",\n" +
" \"authorizationBaseUrl\": \"https://accounts.google.com/o/oauth2/auth\",\n" +
" \"accessTokenEndpoint\": \"https://www.googleapis.com/oauth2/v4/token\",\n" +
" \"scope\": \"email profile\",\n" +
" \"pkceEnabled\": true,\n" +
" \"resourceUrl\": \"https://www.googleapis.com/userinfo/v2/me\",\n" +
" \"web\": {\n" +
" \"redirectUrl\": \"http://localhost:4200\",\n" +
" \"windowOptions\": \"height=600,left=0,top=0\"\n" +
" },\n" +
" \"android\": {\n" +
" \"appId\": \"" +
CLIENT_ID_ANDROID +
"\",\n" +
" \"redirectUrl\": \"com.company.project:/\",\n" +
" \"handleResultMethod\": \"TEST\",\n" +
" \"responseType\": \"TOKEN\"\n" +
" },\n" +
" \"ios\": {\n" +
" \"appId\": \"CLIENT_ID_IOS\",\n" +
" \"responseType\": \"code\",\n" +
" \"redirectUrl\": \"com.company.project:/\"\n" +
" }\n" +
"}\n"
);
OAuth2Options options = plugin.buildAuthenticateOptions(jsObject);
Assertions.assertNotNull(options);
Assertions.assertEquals(CLIENT_ID_ANDROID, options.getAppId());
Assertions.assertEquals("token", options.getResponseType().toLowerCase());
Assertions.assertTrue(options.isHandleResultOnActivityResult());
}
@Test
public void serverAuthorizationHandling() {
JSObject jsObject = loadJson(
"{\n" +
" \"appId\": \"CLIENT_ID\",\n" +
" \"authorizationBaseUrl\": \"https://accounts.google.com/o/oauth2/auth\",\n" +
" \"responseType\": \"code id_token\",\n" +
" \"redirectUrl\": \"https://project.myserver.com/oauth\",\n" +
" \"resourceUrl\": \"https://www.googleapis.com/userinfo/v2/me\",\n" +
" \"scope\": \"email profile\",\n" +
" \"web\": {\n" +
" \"windowOptions\": \"height=600,left=0,top=0\"\n" +
" },\n" +
" \"android\": {\n" +
" \"appId\": \"" +
CLIENT_ID_ANDROID +
"\"\n" +
" },\n" +
" \"ios\": {\n" +
" \"appId\": \"CLIENT_ID_IOS\"\n" +
" }\n" +
"}\n"
);
OAuth2Options options = plugin.buildAuthenticateOptions(jsObject);
Assertions.assertNotNull(options.getAppId());
Assertions.assertEquals(CLIENT_ID_ANDROID, options.getAppId());
Assertions.assertNotNull(options.getAuthorizationBaseUrl());
Assertions.assertEquals("code id_token", options.getResponseType());
Assertions.assertNotNull(options.getRedirectUrl());
}
@Test
public void buildRefreshTokenOptions() {
JSObject jsObject = loadJson(
"{\n" +
" \"appId\": \"CLIENT_ID\",\n" +
" \"accessTokenEndpoint\": \"https://www.googleapis.com/oauth2/v4/token\",\n" +
" \"refreshToken\": \"ss4f6sd5f4\",\n" +
" \"scope\": \"email profile\"\n" +
"}"
);
OAuth2RefreshTokenOptions options = plugin.buildRefreshTokenOptions(jsObject);
Assertions.assertNotNull(options);
Assertions.assertNotNull(options.getAppId());
Assertions.assertNotNull(options.getAccessTokenEndpoint());
Assertions.assertNotNull(options.getRefreshToken());
Assertions.assertNotNull(options.getScope());
}
private JSObject loadJson(String json) {
try {
return new JSObject(json);
} catch (Exception e) {
Log.e("OAuth2", "", e);
}
return null;
}
}
================================================
FILE: ios/.gitignore
================================================
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
.DS_Store
.build
================================================
FILE: ios/Sources/GenericOAuth2Plugin/GenericOAuth2Plugin.swift
================================================
import Foundation
import Capacitor
import OAuthSwift
import CommonCrypto
import AuthenticationServices
typealias JSObject = [String: Any]
/**
* Please read the Capacitor iOS Plugin Development Guide
* here: https://capacitorjs.com/docs/plugins/ios
*/
@objc(GenericOAuth2Plugin)
public class GenericOAuth2Plugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "GenericOAuth2Plugin"
public let jsName = "GenericOAuth2"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "refreshToken", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "authenticate", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "logout", returnType: CAPPluginReturnPromise),
]
var savedPluginCall: CAPPluginCall?
let JSON_KEY_ACCESS_TOKEN = "access_token"
let JSON_KEY_AUTHORIZATION_RESPONSE = "authorization_response"
let JSON_KEY_ACCESS_TOKEN_RESPONSE = "access_token_response"
let PARAM_REFRESH_TOKEN = "refreshToken"
// required
let PARAM_APP_ID = "appId"
let PARAM_AUTHORIZATION_BASE_URL = "authorizationBaseUrl"
let PARAM_RESPONSE_TYPE = "responseType"
let PARAM_REDIRECT_URL = "redirectUrl"
// controlling
let PARAM_ACCESS_TOKEN_ENDPOINT = "accessTokenEndpoint"
let PARAM_RESOURCE_URL = "resourceUrl"
let PARAM_ADDITIONAL_RESOURCE_HEADERS = "additionalResourceHeaders"
let PARAM_ADDITIONAL_PARAMETERS = "additionalParameters"
let PARAM_CUSTOM_HANDLER_CLASS = "ios.customHandlerClass"
let PARAM_SCOPE = "scope"
let PARAM_STATE = "state"
let PARAM_PKCE_ENABLED = "pkceEnabled"
let PARAM_IOS_USE_SCOPE = "ios.siwaUseScope"
let PARAM_LOGOUT_URL = "logoutUrl"
let PARAM_LOGS_ENABLED = "logsEnabled"
let ERR_GENERAL = "ERR_GENERAL"
// authenticate param validation
let ERR_PARAM_NO_APP_ID = "ERR_PARAM_NO_APP_ID"
let ERR_PARAM_NO_AUTHORIZATION_BASE_URL = "ERR_PARAM_NO_AUTHORIZATION_BASE_URL"
let ERR_PARAM_NO_RESPONSE_TYPE = "ERR_PARAM_NO_RESPONSE_TYPE"
let ERR_PARAM_NO_REDIRECT_URL = "ERR_PARAM_NO_REDIRECT_URL"
// refreshToken param validation
let ERR_PARAM_NO_REFRESH_TOKEN = "ERR_PARAM_NO_REFRESH_TOKEN"
let ERR_PARAM_NO_ACCESS_TOKEN_ENDPOINT = "ERR_PARAM_NO_ACCESS_TOKEN_ENDPOINT"
let ERR_CUSTOM_HANDLER_LOGIN = "ERR_CUSTOM_HANDLER_LOGIN"
let ERR_CUSTOM_HANDLER_LOGOUT = "ERR_CUSTOM_HANDLER_LOGOUT"
let ERR_STATES_NOT_MATCH = "ERR_STATES_NOT_MATCH"
let ERR_NO_AUTHORIZATION_CODE = "ERR_NO_AUTHORIZATION_CODE"
let ERR_AUTHORIZATION_FAILED = "ERR_AUTHORIZATION_FAILED"
struct SharedConstants {
static let ERR_USER_CANCELLED = "USER_CANCELLED"
}
var oauthSwift: OAuth2Swift?
var oauth2SafariDelegate: OAuth2SafariDelegate?
var handlerClasses = [String: OAuth2CustomHandler.Type]()
var handlerInstances = [String: OAuth2CustomHandler]()
func registerHandlers() {
let classCount = objc_getClassList(nil, 0)
let classes = UnsafeMutablePointer.allocate(capacity: Int(classCount))
let releasingClasses = AutoreleasingUnsafeMutablePointer(classes)
let numClasses: Int32 = objc_getClassList(releasingClasses, classCount)
for i in 0..,
_ call: CAPPluginCall,
_ responseType: String,
_ requestState: String,
_ logsEnabled: Bool,
_ resourceUrl: String?) {
switch result {
case .success(let (credential, response, parameters)):
if logsEnabled, let accessTokenResponse = response {
logDataObj("Authorization or Access token response:", accessTokenResponse.data)
}
// state is aready checked by the lib
if resourceUrl != nil && !resourceUrl!.isEmpty {
if logsEnabled {
log("Resource url: \(resourceUrl!)")
log("Access token:\n\(credential.oauthToken)")
}
// resource url request headers
let callParameter: [String: Any] = getOverwritable(call, PARAM_ADDITIONAL_RESOURCE_HEADERS) as? [String: Any] ?? [:]
let additionalHeadersDict = buildStringDict(callParameter)
self.oauthSwift!.client.get(resourceUrl!,
headers: additionalHeadersDict) { result in
switch result {
case .success(let resourceResponse):
do {
if logsEnabled {
self.logDataObj("Resource response:", resourceResponse.data)
}
var jsonObj = try JSONSerialization.jsonObject(with: resourceResponse.data, options: []) as! JSObject
// send the access token to the caller so e.g. it can be stored on a backend
// #154
if let accessTokenResponse = response {
let accessTokenJsObject = try? JSONSerialization.jsonObject(with: accessTokenResponse.data, options: []) as? JSObject
jsonObj.updateValue(accessTokenJsObject!, forKey: self.JSON_KEY_ACCESS_TOKEN_RESPONSE)
}
jsonObj.updateValue(credential.oauthToken, forKey: self.JSON_KEY_ACCESS_TOKEN)
if logsEnabled {
self.log("Returned to JS:\n\(jsonObj)")
}
call.resolve(jsonObj)
} catch {
self.log("Invalid json in resource response:\n \(error.localizedDescription)")
call.reject(self.ERR_GENERAL)
}
case .failure(let error):
self.log("Resource url request failed:\n\(error.description)")
call.reject(self.ERR_GENERAL)
}
}
// no resource url
} else if let responseData = response?.data {
do {
var jsonObj = JSObject()
let accessTokenJsObject = try? JSONSerialization.jsonObject(with: responseData, options: []) as? JSObject
jsonObj.updateValue(accessTokenJsObject!, forKey: self.JSON_KEY_ACCESS_TOKEN_RESPONSE)
if logsEnabled {
self.log("Returned to JS:\n\(jsonObj)")
}
call.resolve(jsonObj)
} catch {
self.log("Invalid json in response \(error.localizedDescription)")
call.reject(self.ERR_GENERAL)
}
} else {
// `parameters` will be response parameters
var result = parameters
result.updateValue(credential.oauthToken, forKey: self.JSON_KEY_ACCESS_TOKEN)
call.resolve(parameters)
}
case .failure(let error):
switch error {
case .cancelled, .accessDenied:
call.reject(SharedConstants.ERR_USER_CANCELLED)
case .stateNotEqual:
self.log("The given state does not match the one in the respond!")
call.reject(self.ERR_STATES_NOT_MATCH)
default:
self.log("Authorization failed with \(error.localizedDescription)")
call.reject(self.ERR_NO_AUTHORIZATION_CODE)
}
}
}
private func getConfigObjectDeepest(_ options: [AnyHashable: Any?]!, key: String) -> [AnyHashable: Any?]? {
let parts = key.split(separator: ".")
var o = options
for (_, k) in parts[0.. String {
let parts = key.split(separator: ".")
if parts.last != nil {
return String(parts.last!)
}
return ""
}
private func getOverwritableString(_ call: CAPPluginCall, _ key: String) -> String? {
var base = getString(call, key)
let ios = getString(call, "ios." + key)
if ios != nil {
base = ios
}
return base
}
private func getOverwritable(_ call: CAPPluginCall, _ key: String) -> Any? {
var base = getValue(call, key)
let ios = getValue(call, "ios." + key)
if ios != nil {
base = ios
}
return base
}
private func getValue(_ call: CAPPluginCall, _ key: String) -> Any? {
let k = getConfigKey(key)
let o = getConfigObjectDeepest(call.options, key: key)
return o?[k] ?? nil
}
private func getString(_ call: CAPPluginCall, _ key: String) -> String? {
let value = getValue(call, key)
if value == nil {
return nil
}
return value as? String
}
private func getOrLoadHandlerInstance(className: String) -> OAuth2CustomHandler? {
guard let instance = self.getHandlerInstance(className: className) ?? self.loadHandlerInstance(className: className) else {
return nil
}
return instance
}
private func getHandlerInstance(className: String) -> OAuth2CustomHandler? {
return self.handlerInstances[className]
}
private func log(_ msg: String) {
print("I/Capacitor/GenericOAuth2Plugin: \(msg)")
}
private func logDataObj(_ msg: String, _ data: Data) {
let json = try? JSONSerialization.jsonObject(with: data, options: [])
log("\(msg)\n\(json ?? "")")
}
private func buildStringDict(_ callParameter: [String: Any]) -> [String: String] {
var dict: [String: String] = [:]
for (key, value) in callParameter {
// only non empty string values are allowed
if !key.isEmpty && value is String {
let str = value as! String
if !str.isEmpty {
dict[key] = str
}
}
}
return dict
}
private func loadHandlerInstance(className: String) -> OAuth2CustomHandler? {
guard let handlerClazz: OAuth2CustomHandler.Type = self.handlerClasses[className] else {
log("Unable to load custom handler \(className). No such class found.")
return nil
}
let instance: OAuth2CustomHandler = handlerClazz.init()
self.handlerInstances[className] = instance
return instance
}
private func generateRandom(withLength len: Int) -> String {
let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
let length = UInt32(letters.count)
var randomString = ""
for _ in 0.. Data {
let data = self.data(using: .utf8)!
var buffer = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
data.withUnsafeBytes {
_ = CC_SHA256($0.baseAddress, CC_LONG(data.count), &buffer)
}
return Data(buffer)
}
}
extension Data {
func base64() -> String {
return self.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
.trimmingCharacters(in: .whitespaces)
}
}
@available(iOS 13.0, *)
extension GenericOAuth2Plugin: ASAuthorizationControllerDelegate {
func handleSignInWithApple(_ call: CAPPluginCall) {
self.savedPluginCall = call
let appleIDProvider = ASAuthorizationAppleIDProvider()
let request = appleIDProvider.createRequest()
if let _: Bool = getValue(call, PARAM_IOS_USE_SCOPE) as? Bool {
if let scopeStr = getOverwritableString(call, PARAM_SCOPE), !scopeStr.isEmpty {
var scopeArr: [ASAuthorization.Scope] = []
if scopeStr.localizedCaseInsensitiveContains("fullName")
|| scopeStr.localizedCaseInsensitiveContains("name") {
scopeArr.append(.fullName)
}
if scopeStr.localizedCaseInsensitiveContains("email") {
scopeArr.append(.email)
}
request.requestedScopes = scopeArr
}
} else {
request.requestedScopes = [.fullName, .email]
}
let authorizationController = ASAuthorizationController(authorizationRequests: [request])
authorizationController.delegate = self
authorizationController.performRequests()
}
public func authorizationController(controller: ASAuthorizationController,
didCompleteWithAuthorization authorization: ASAuthorization) {
switch authorization.credential {
case let appleIDCredential as ASAuthorizationAppleIDCredential:
var realUserStatus: String
switch appleIDCredential.realUserStatus {
case .likelyReal:
realUserStatus = "likelyReal"
case .unknown:
realUserStatus = "unknown"
case .unsupported:
realUserStatus = "unsupported"
@unknown default:
realUserStatus = ""
}
let result = [
"id": appleIDCredential.user,
"given_name": appleIDCredential.fullName?.givenName as Any,
"family_name": appleIDCredential.fullName?.familyName as Any,
"email": appleIDCredential.email as Any,
"real_user_status": realUserStatus,
"state": appleIDCredential.state as Any,
"id_token": String(data: appleIDCredential.identityToken!, encoding: .utf8) as Any,
"code": String(data: appleIDCredential.authorizationCode!, encoding: .utf8) as Any
] as [String: Any]
self.savedPluginCall?.resolve(result as PluginCallResultData)
default:
self.log("SIWA: Authorization failed!")
self.savedPluginCall?.reject(self.ERR_AUTHORIZATION_FAILED)
}
}
public func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
// Handle error.
guard let error = error as? ASAuthorizationError else {
return
}
switch error.code {
case .canceled:
self.savedPluginCall?.reject(SharedConstants.ERR_USER_CANCELLED)
case .unknown:
self.log("SIWA: Error.unknown.")
self.savedPluginCall?.reject(SharedConstants.ERR_USER_CANCELLED)
case .invalidResponse:
self.log("SIWA: Error.invalidResponse")
self.savedPluginCall?.reject(self.ERR_AUTHORIZATION_FAILED)
case .notHandled:
self.log("SIWA: Error.notHandled")
self.savedPluginCall?.reject(self.ERR_AUTHORIZATION_FAILED)
case .failed:
self.log("SIWA: Error.failed")
self.savedPluginCall?.reject(self.ERR_AUTHORIZATION_FAILED)
@unknown default:
self.log("SIWA: Error.default")
self.savedPluginCall?.reject(self.ERR_GENERAL)
}
}
}
================================================
FILE: ios/Sources/GenericOAuth2Plugin/OAuth2CustomHandler.swift
================================================
import Foundation
import Capacitor
@objc public protocol OAuth2CustomHandler: NSObjectProtocol {
init()
func getAccessToken(viewController: UIViewController, call: CAPPluginCall,
success: @escaping (_ accessToken: String) -> Void,
cancelled: @escaping () -> Void,
failure: @escaping (_ error: Error) -> Void)
func logout(viewController: UIViewController, call: CAPPluginCall) -> Bool
}
================================================
FILE: ios/Sources/GenericOAuth2Plugin/OAuth2SafariDelegate.swift
================================================
import Foundation
import Capacitor
import SafariServices
class OAuth2SafariDelegate: NSObject, SFSafariViewControllerDelegate {
var pluginCall: CAPPluginCall
init(_ call: CAPPluginCall) {
self.pluginCall = call
}
func safariViewControllerDidFinish(_ controller: SFSafariViewController) {
self.pluginCall.reject(GenericOAuth2Plugin.SharedConstants.ERR_USER_CANCELLED)
}
}
================================================
FILE: ios/Tests/GenericOAuth2PluginTests/GenericOAuth2Tests.swift
================================================
import XCTest
@testable import GenericOAuth2Plugin
class GenericOAuth2Tests: XCTestCase {
override func setUp() {
super.setUp()
// Put setup code here. This method is called before the invocation of each test method in the class.
}
override func tearDown() {
// Put teardown code here. This method is called after the invocation of each test method in the class.
super.tearDown()
}
func testEcho() {
// This is an example of a functional test case for a plugin.
// Use XCTAssert and related functions to verify your tests produce the correct results.
let implementation = GenericOAuth2()
let value = "Hello, World!"
let result = implementation.echo(value)
XCTAssertEqual(value, result)
}
}
================================================
FILE: jest.config.js
================================================
module.exports = {
preset: 'ts-jest',
verbose: true,
testEnvironment: 'node',
globals: {
window: {},
},
};
================================================
FILE: package.json
================================================
{
"name": "@capacitor-community/generic-oauth2",
"version": "7.1.0",
"description": "Capacitor OAuth 2 client plugin",
"main": "dist/plugin.cjs.js",
"module": "dist/esm/index.js",
"types": "dist/esm/index.d.ts",
"unpkg": "dist/plugin.js",
"files": [
"android/src/main/",
"android/build.gradle",
"dist/",
"ios/Sources",
"ios/Tests",
"Package.swift",
"CapacitorCommunityGenericOAuth2.podspec"
],
"author": "",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/capacitor-community/generic-oauth2.git"
},
"bugs": {
"url": "https://github.com/capacitor-community/generic-oauth2/issues"
},
"keywords": [
"capacitor",
"capacitor-plugin",
"oauth2",
"oauth2-client",
"social-login"
],
"scripts": {
"verify": "npm run verify:ios && npm run verify:android && npm run verify:web",
"verify:ios": "xcodebuild -scheme CapacitorCommunityGenericOauth2 -destination generic/platform=iOS",
"verify:android": "cd android && ./gradlew clean build test && cd ..",
"verify:web": "npm run build",
"lint": "npm run eslint && npm run prettier -- --check && npm run swiftlint -- lint",
"fmt": "npm run eslint -- --fix && npm run prettier -- --write && npm run swiftlint -- --fix --format",
"eslint": "eslint . --ext ts",
"prettier": "prettier \"**/*.{css,html,ts,js,java}\" --plugin=prettier-plugin-java",
"swiftlint": "node-swiftlint",
"docgen": "docgen --api GenericOAuth2Plugin --output-readme README.md --output-json dist/docs.json",
"build": "npm run clean && npm run docgen && tsc && rollup -c rollup.config.mjs",
"clean": "rimraf ./dist",
"watch": "tsc --watch",
"test": "jest",
"removePacked": "rimraf -g capacitor-community-generic-oauth2-*.tgz",
"publish:locally": "npm run removePacked && npm run build && npm pack",
"prepublishOnly": "npm run build"
},
"devDependencies": {
"@capacitor/android": "7.4.2",
"@capacitor/core": "7.4.2",
"@capacitor/docgen": "0.3.0",
"@capacitor/ios": "7.4.2",
"@ionic/eslint-config": "0.4.0",
"@ionic/prettier-config": "4.0.0",
"@ionic/swiftlint-config": "2.0.0",
"@types/jest": "30.0.0",
"@types/node": "24.1.0",
"eslint": "9.32.0",
"jest": "30.0.5",
"prettier": "3.6.2",
"prettier-plugin-java": "2.7.3",
"rimraf": "6.0.1",
"rollup": "4.46.0",
"swiftlint": "2.0.0",
"ts-jest": "29.4.0",
"typescript": "5.8.3"
},
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
},
"prettier": "@ionic/prettier-config",
"swiftlint": "@ionic/swiftlint-config",
"eslintConfig": {
"extends": "@ionic/eslint-config/recommended"
},
"capacitor": {
"ios": {
"src": "ios"
},
"android": {
"src": "android"
}
}
}
================================================
FILE: rollup.config.mjs
================================================
export default {
input: 'dist/esm/index.js',
output: [
{
file: 'dist/plugin.js',
format: 'iife',
name: 'capacitorGenericOAuth2',
globals: {
'@capacitor/core': 'capacitorExports',
},
sourcemap: true,
inlineDynamicImports: true,
},
{
file: 'dist/plugin.cjs.js',
format: 'cjs',
sourcemap: true,
inlineDynamicImports: true,
},
],
external: ['@capacitor/core'],
};
================================================
FILE: src/definitions.ts
================================================
export interface GenericOAuth2Plugin {
/**
* Authenticate against a OAuth 2 provider.
* @param {OAuth2AuthenticateOptions} options
* @returns {Promise} the resource url response
*/
authenticate(options: OAuth2AuthenticateOptions): Promise;
/**
* Listens for OAuth implicit redirect flow queryString CODE to generate an access_token
* @param {OAuth2RedirectAuthenticationOptions} options
* @returns {Promise} the token endpoint response
*/
redirectFlowCodeListener(
options: ImplicitFlowRedirectOptions,
): Promise;
/**
* Get a new access token based on the given refresh token.
* @param {OAuth2RefreshTokenOptions} options
* @returns {Promise} the token endpoint response
*/
refreshToken(options: OAuth2RefreshTokenOptions): Promise;
/**
* Logout from the authenticated OAuth 2 provider
* @param {OAuth2AuthenticateOptions} options Although not all options are needed. We simply reuse the options from authenticate
* @param {String} id_token Optional idToken, only for Android
* @returns {Promise} true if the logout was successful else false.
*/
logout(
options: OAuth2AuthenticateOptions,
id_token?: string,
): Promise;
}
export interface ImplicitFlowRedirectOptions extends OAuth2AuthenticateOptions {
/**
* The URL where we get the code
*/
response_url: string;
}
export interface OAuth2RefreshTokenOptions {
/**
* The app id (client id) you get from the oauth provider like Google, Facebook,...
*/
appId: string;
/**
* Url for retrieving the access_token.
*/
accessTokenEndpoint: string;
/**
* The refresh token that will be used to obtain the new access token.
*/
refreshToken: string;
/**
* A space-delimited list of permissions that identify the resources that your application could access on the user's behalf.
*/
scope?: string;
}
export interface OAuth2AuthenticateBaseOptions {
/**
* The app id (client id) you get from the oauth provider like Google, Facebook,...
*
* required!
*/
appId?: string;
/**
* The base url for retrieving tokens depending on the response type from a OAuth 2 provider. e.g. https://accounts.google.com/o/oauth2/auth
*
* required!
*/
authorizationBaseUrl?: string;
/**
* Tells the authorization server which grant to execute. Be aware that a full code flow is not supported as clientCredentials are not included in requests.
*
* But you can retrieve the authorizationCode if you don't set a accessTokenEndpoint.
*
* required!
*/
responseType?: string;
/**
* Url to which the oauth provider redirects after authentication.
*
* required!
*/
redirectUrl?: string;
/**
* Url for retrieving the access_token by the authorization code flow.
*/
accessTokenEndpoint?: string;
/**
* Protected resource url. For authentication you only need the basic user details.
*/
resourceUrl?: string;
/**
* Enable PKCE if you need it.
*/
pkceEnabled?: boolean;
/**
* A space-delimited list of permissions that identify the resources that your application could access on the user's behalf.
* If you want to get a refresh token, you most likely will need the offline_access scope (only supported in Code Flow!)
*/
scope?: string;
/**
* A unique alpha numeric string used to prevent CSRF. If not set the plugin automatically generate a string
* and sends it as using state is recommended.
*/
state?: string;
/**
* Additional parameters for the created authorization url
*/
additionalParameters?: { [key: string]: string };
/**
* @since 3.0.0
*/
logsEnabled?: boolean;
/**
* @since 3.1.0 ... not implemented yet!
*/
logoutUrl?: string;
/**
* Additional headers for resource url request
* @since 3.0.0
*/
additionalResourceHeaders?: { [key: string]: string };
}
export interface OAuth2AuthenticateOptions
extends OAuth2AuthenticateBaseOptions {
/**
* Custom options for the platform "web"
*/
web?: WebOption;
/**
* Custom options for the platform "android"
*/
android?: AndroidOptions;
/**
* Custom options for the platform "ios"
*/
ios?: IosOptions;
}
export interface WebOption extends OAuth2AuthenticateBaseOptions {
/**
* Options for the window the plugin open for authentication. e.g. width=500,height=600,left=0,top=0
*/
windowOptions?: string;
/**
* Options for the window target. Defaults to _blank
*/
windowTarget?: string;
/**
* Whether to send the cache control header with the token request, unsupported by some providers. Defaults to true.
*/
sendCacheControlHeader?: boolean;
}
export interface AndroidOptions extends OAuth2AuthenticateBaseOptions {
/**
* Some oauth provider especially Facebook forces us to use their SDK for apps.
*
* Provide a class name implementing the 'CapacitorCommunityGenericOAuth2.OAuth2CustomHandler' protocol.
*/
customHandlerClass?: string;
/**
* Alternative to handle the activity result. The `onNewIntent` method is only call if the App was killed while logging in.
*/
handleResultOnNewIntent?: boolean;
/**
* Default handling the activity result.
*/
handleResultOnActivityResult?: boolean;
}
export interface IosOptions extends OAuth2AuthenticateBaseOptions {
/**
* If true the iOS 13+ feature Sign in with Apple (SiWA) try to build the scope from the standard "scope" parameter.
*
* If false scope is set to email and fullName.
*/
siwaUseScope?: boolean;
/**
* Some oauth provider especially Facebook forces us to use their SDK for apps.
*
* Provide a class name implementing the 'CapacitorCommunityGenericOAuth2.OAuth2CustomHandler' protocol.
*/
customHandlerClass?: string;
}
================================================
FILE: src/index.ts
================================================
import { registerPlugin } from '@capacitor/core';
import type { GenericOAuth2Plugin } from './definitions';
const GenericOAuth2 = registerPlugin('GenericOAuth2', {
web: () => import('./web').then(m => new m.GenericOAuth2Web()),
});
export * from './definitions';
export { GenericOAuth2 };
================================================
FILE: src/web-utils.test.ts
================================================
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type { OAuth2AuthenticateOptions } from './definitions';
import { CryptoUtils, WebUtils } from './web-utils';
const mGetRandomValues = jest.fn().mockReturnValueOnce(new Uint32Array(10));
Object.defineProperty(window, 'crypto', {
value: { getRandomValues: mGetRandomValues },
});
let store: {
[k: string]: string;
} = {};
const sessionStorageMock = {
getItem: jest.fn().mockImplementation((key: string) => store[key] ?? null),
setItem: jest
.fn()
.mockImplementation((key: string, value: string) => (store[key] = value)),
removeItem: jest.fn().mockImplementation((key: string) => delete store[key]),
clear: jest.fn().mockImplementation(() => (store = {})),
};
Object.defineProperty(window, 'sessionStorage', {
value: sessionStorageMock,
});
const googleOptions: OAuth2AuthenticateOptions = {
appId: 'appId',
authorizationBaseUrl: 'https://accounts.google.com/o/oauth2/auth',
accessTokenEndpoint: 'https://www.googleapis.com/oauth2/v4/token',
scope: 'email profile',
resourceUrl: 'https://www.googleapis.com/userinfo/v2/me',
pkceEnabled: false,
web: {
accessTokenEndpoint: '',
redirectUrl: 'https://oauth2.byteowls.com/authorize',
appId: 'webAppId',
pkceEnabled: true,
},
android: {
responseType: 'code',
},
ios: {
responseType: 'code',
},
};
const oneDriveOptions: OAuth2AuthenticateOptions = {
appId: 'appId',
authorizationBaseUrl:
'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
accessTokenEndpoint:
'https://login.microsoftonline.com/common/oauth2/v2.0/token',
scope: 'files.readwrite offline_access',
responseType: 'code',
additionalParameters: {
willbeoverwritten: 'foobar',
},
web: {
redirectUrl: 'https://oauth2.byteowls.com/authorize',
pkceEnabled: false,
additionalParameters: {
'resource': 'resource_id',
'emptyParam': null!,
' ': 'test',
'nonce': WebUtils.randomString(10),
},
},
android: {
redirectUrl: 'com.byteowls.oauth2://authorize',
},
ios: {
redirectUrl: 'com.byteowls.oauth2://authorize',
},
};
const implicitFlowOptions: OAuth2AuthenticateOptions = {
...oneDriveOptions,
pkceEnabled: true,
web: {
...oneDriveOptions.web,
pkceEnabled: true,
},
};
const redirectUrlOptions: OAuth2AuthenticateOptions = {
appId: 'appId',
authorizationBaseUrl:
'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
responseType: 'code',
redirectUrl: 'https://mycompany.server.com/oauth',
scope: 'files.readwrite offline_access',
additionalParameters: {
willbeoverwritten: 'foobar',
},
web: {},
android: {
redirectUrl: 'com.byteowls.oauth2://authorize',
},
ios: {
redirectUrl: 'com.byteowls.oauth2://authorize',
},
};
describe('base options processing', () => {
it('should build a nested appId', () => {
const appId = WebUtils.getAppId(googleOptions);
expect(appId).toEqual('webAppId');
});
it('should build a overwritable string value', () => {
const appId = WebUtils.getOverwritableValue(googleOptions, 'appId');
expect(appId).toEqual('webAppId');
});
it('should build a overwritable boolean value', () => {
const pkceEnabled = WebUtils.getOverwritableValue(
googleOptions,
'pkceEnabled',
);
expect(pkceEnabled).toBeTruthy();
});
it('should build a overwritable additional parameters map', () => {
const additionalParameters = WebUtils.getOverwritableValue<{
[key: string]: string;
}>(oneDriveOptions, 'additionalParameters');
expect(additionalParameters).not.toBeUndefined();
expect(additionalParameters['resource']).toEqual('resource_id');
});
it('must not contain overwritten additional parameters', () => {
const additionalParameters = WebUtils.getOverwritableValue<{
[key: string]: string;
}>(oneDriveOptions, 'additionalParameters');
expect(additionalParameters['willbeoverwritten']).toBeUndefined();
});
it('must have a base redirect url', () => {
const redirectUrl = WebUtils.getOverwritableValue(
redirectUrlOptions,
'redirectUrl',
);
expect(redirectUrl).toBeDefined();
});
it('must be overwritten by empty string from web section', () => {
const accessTokenEndpoint = WebUtils.getOverwritableValue(
googleOptions,
'accessTokenEndpoint',
);
expect(accessTokenEndpoint).toStrictEqual('');
});
it('must not be overwritten if no key exists in web section', () => {
const accessTokenEndpoint = WebUtils.getOverwritableValue(
googleOptions,
'scope',
);
expect(accessTokenEndpoint).toStrictEqual('email profile');
});
});
describe('web options', () => {
it('should build web options', async () => {
WebUtils.buildWebOptions(oneDriveOptions).then(webOptions => {
expect(webOptions).not.toBeNull();
});
});
it('should not have a code verifier', async () => {
WebUtils.buildWebOptions(oneDriveOptions).then(webOptions => {
expect(webOptions.pkceCodeVerifier).toBeUndefined();
});
});
it('must not contain empty additional parameter', async () => {
WebUtils.buildWebOptions(oneDriveOptions).then(webOptions => {
expect(webOptions.additionalParameters[' ']).toBeUndefined();
expect(webOptions.additionalParameters['emptyParam']).toBeUndefined();
});
});
it('must have the sendCacheControlHeader enabled by default', async () => {
WebUtils.buildWebOptions(oneDriveOptions).then(webOptions => {
expect(webOptions.sendCacheControlHeader).toBeTruthy();
});
});
it('must allow the sendCacheControlHeader to be set to false', async () => {
WebUtils.buildWebOptions({
web: {
sendCacheControlHeader: false,
},
}).then(webOptions => {
expect(webOptions.sendCacheControlHeader).toBeFalsy();
});
});
describe('if pkceCode enabled', () => {
beforeEach(() => {
sessionStorageMock.clear();
});
describe('if a code exists in sessionStorage', () => {
beforeEach(() => {
const code = 'DEMO_CODE';
WebUtils.setCodeVerifier(code);
});
it('should get the code correctly', async () => {
const spy = jest.spyOn(WebUtils, 'getCodeVerifier');
const webOptions = await WebUtils.buildWebOptions(implicitFlowOptions);
expect(spy).toHaveBeenCalled();
expect(webOptions.pkceCodeVerifier).toBe('DEMO_CODE');
});
});
describe("if a code doesn't exist in sessionStorage", () => {
it('should set the code', async () => {
const spy = jest.spyOn(WebUtils, 'setCodeVerifier');
const webOptions = await WebUtils.buildWebOptions(implicitFlowOptions);
expect(webOptions.pkceCodeVerifier).toBeDefined();
expect(spy).toHaveBeenCalled();
});
});
});
});
describe('Url param extraction', () => {
it('should return undefined on null url', () => {
const paramObj = WebUtils.getUrlParams(null!);
expect(paramObj).toBeUndefined();
});
it('should return undefined on empty url', () => {
const paramObj = WebUtils.getUrlParams('');
expect(paramObj).toBeUndefined();
});
it('should return undefined on url with spaces', () => {
const paramObj = WebUtils.getUrlParams(' ');
expect(paramObj).toBeUndefined();
});
it('should return undefined if no params in url', () => {
const paramObj = WebUtils.getUrlParams('https://app.example.com/');
expect(paramObj).toBeUndefined();
});
it('should return undefined if no params in url search', () => {
const paramObj = WebUtils.getUrlParams('https://app.example.com?');
expect(paramObj).toBeUndefined();
});
it('should return undefined if no params in url hash', () => {
const paramObj = WebUtils.getUrlParams('https://app.example.com#');
expect(paramObj).toBeUndefined();
});
it('should remove invalid combinations one param', () => {
const paramObj = WebUtils.getUrlParams('https://app.example.com?=test');
expect(paramObj).toBeUndefined();
});
it('should remove invalid combinations multiple param', () => {
const paramObj = WebUtils.getUrlParams(
'https://app.example.com?=test&key1=param1',
);
expect(paramObj).toEqual({ key1: 'param1' });
});
it('should extract work with a single param', () => {
const paramObj = WebUtils.getUrlParams(
'https://app.example.com?access_token=testtoken',
);
expect(paramObj!['access_token']).toStrictEqual('testtoken');
});
it('should extract a uuid state param', () => {
const state = WebUtils.randomString();
const paramObj = WebUtils.getUrlParams(
`https://app.example.com?state=${state}&access_token=testtoken`,
);
expect(paramObj!['state']).toStrictEqual(state);
});
it('should use query flag and ignore hash flag', () => {
const random = WebUtils.randomString();
const foo = WebUtils.randomString();
const paramObj = WebUtils.getUrlParams(
`https://app.example.com?random=${random}&foo=${foo}#ignored`,
);
expect(paramObj!['random']).toStrictEqual(random);
expect(paramObj!['foo']).toStrictEqual(foo);
});
it('should use query flag with another question mark in a param', () => {
const random = WebUtils.randomString();
const foo = WebUtils.randomString();
const paramObj = WebUtils.getUrlParams(
`https://app.example.com?random=${random}&foo=${foo}?questionmark`,
);
expect(paramObj!['random']).toStrictEqual(random);
expect(paramObj!['foo']).toStrictEqual(`${foo}?questionmark`);
});
it('should use hash flag and ignore query flag', () => {
const random = WebUtils.randomString();
const foo = WebUtils.randomString();
const paramObj = WebUtils.getUrlParams(
`https://app.example.com#random=${random}&foo=${foo}?ignored`,
);
expect(paramObj!['random']).toStrictEqual(random);
expect(paramObj!['foo']).toStrictEqual(`${foo}?ignored`);
});
it('should use hash flag with another hash in a param', () => {
const random = WebUtils.randomString();
const foo = WebUtils.randomString();
const paramObj = WebUtils.getUrlParams(
`https://app.example.com#random=${random}&foo=${foo}#hash`,
);
expect(paramObj!['random']).toStrictEqual(random);
expect(paramObj!['foo']).toStrictEqual(`${foo}#hash`);
});
it('should extract hash params correctly', () => {
const random = WebUtils.randomString(20);
const url = `http://localhost:4200/#state=${random}&access_token=ya29.a0ARrdaM-sdfsfsdfsdfsdfs-YGFHwg_lM6dePPaT_TunbpsdfsdfsdfsEG6vTVLsLJDDW
tv5m1Q8_g3hXraaoELYGsjl53&token_type=Bearer&expires_in=3599&scope=email%20profile%20openid%20
https://www.googleapis.com/auth/userinfo.email%20https://www.googleapis.com/auth/userinfo.profile&authuser=0&prompt=none`;
const paramObj = WebUtils.getUrlParams(url);
expect(paramObj!['access_token']).toBeDefined();
expect(paramObj!['token_type']).toStrictEqual('Bearer');
expect(paramObj!['prompt']).toBeDefined();
expect(paramObj!['state']).toStrictEqual(random);
});
it('should extract hash params if search param indicator present', () => {
const token = 'sldfskdjflsdf12302';
const url = `http://localhost:3000/login?#access_token=${token}`;
const paramObj = WebUtils.getUrlParams(url);
expect(paramObj!['access_token']).toStrictEqual(token);
});
});
describe('Random string gen', () => {
it('should generate a 10 letter string', () => {
const expected = 10;
const random = WebUtils.randomString(expected);
expect(random.length).toStrictEqual(expected);
});
it('should generate a 43 letter string as this is the minimum for PKCE', () => {
const expected = 43;
const random = WebUtils.randomString(expected);
expect(random.length).toStrictEqual(expected);
});
});
describe('Authorization url building', () => {
it('should contain a nonce param', async () => {
WebUtils.buildWebOptions(oneDriveOptions).then(webOptions => {
const authorizationUrl = WebUtils.getAuthorizationUrl(webOptions);
expect(authorizationUrl).toContain('nonce');
});
});
});
describe('Crypto utils', () => {
it('base 64 simple', () => {
const arr: Uint8Array = CryptoUtils.toUint8Array('tester');
const expected = CryptoUtils.toBase64(arr);
expect(expected).toEqual('dGVzdGVy');
});
it('base 64 special char', () => {
const arr: Uint8Array = CryptoUtils.toUint8Array('testerposfieppw2874929');
const expected = CryptoUtils.toBase64(arr);
expect(expected).toEqual('dGVzdGVycG9zZmllcHB3Mjg3NDkyOQ==');
});
it('base 64 with space', () => {
const arr: Uint8Array = CryptoUtils.toUint8Array('base64 encoder');
const expected = CryptoUtils.toBase64(arr);
expect(expected).toEqual('YmFzZTY0IGVuY29kZXI=');
});
it('base64url safe all base64 special chars included', () => {
const expected = CryptoUtils.toBase64Url('YmFz+TY0IG/uY29kZXI=');
expect(expected).toEqual('YmFz-TY0IG_uY29kZXI');
});
});
describe('additional resource headers', () => {
const headerKey = 'Access-Control-Allow-Origin';
const options: OAuth2AuthenticateOptions = {
appId: 'appId',
authorizationBaseUrl:
'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
accessTokenEndpoint:
'https://login.microsoftonline.com/common/oauth2/v2.0/token',
scope: 'files.readwrite offline_access',
responseType: 'code',
additionalResourceHeaders: {
'Access-Control-Allow-Origin': 'will-be-overwritten',
},
web: {
redirectUrl: 'https://oauth2.byteowls.com/authorize',
pkceEnabled: false,
additionalResourceHeaders: {
'Access-Control-Allow-Origin': '*',
},
},
};
it('should be defined', async () => {
const webOptions = await WebUtils.buildWebOptions(options);
expect(webOptions.additionalResourceHeaders[headerKey]).toBeDefined();
});
it('should equal *', async () => {
const webOptions = await WebUtils.buildWebOptions(options);
expect(webOptions.additionalResourceHeaders[headerKey]).toEqual('*');
});
});
describe('implicit redirect authentication flow helpers', () => {
beforeEach(() => {
sessionStorageMock.clear();
});
it('should set code in session storage', () => {
const code = 'DEMO_CODE';
const codeSet = WebUtils.setCodeVerifier(code);
expect(window.sessionStorage.setItem).toHaveBeenCalledWith(
`I_Capacitor_GenericOAuth2Plugin_PKCE`,
code,
);
expect(codeSet).toEqual(true);
});
it('should get code if it exists in sessionStorage', () => {
const code = 'DEMO_CODE';
WebUtils.setCodeVerifier(code);
const readCode = WebUtils.getCodeVerifier();
expect(readCode).toBe(code);
expect(window.sessionStorage.getItem).toHaveBeenCalledWith(
`I_Capacitor_GenericOAuth2Plugin_PKCE`,
);
});
it("should get null if code doesn't exist in sessionStorage", () => {
const readCode = WebUtils.getCodeVerifier();
expect(readCode).toBeNull();
expect(window.sessionStorage.getItem).toHaveBeenCalledWith(
`I_Capacitor_GenericOAuth2Plugin_PKCE`,
);
});
it('should remove the code from sessionStorage', () => {
WebUtils.clearCodeVerifier();
expect(window.sessionStorage.removeItem).toHaveBeenCalledWith(
`I_Capacitor_GenericOAuth2Plugin_PKCE`,
);
});
});
================================================
FILE: src/web-utils.ts
================================================
import type { OAuth2AuthenticateOptions } from './definitions';
// import sha256 from "fast-sha256";
export class WebUtils {
/**
* Public only for testing
*/
static getAppId(options: OAuth2AuthenticateOptions): string {
return this.getOverwritableValue(options, 'appId');
}
static getOverwritableValue(
options: OAuth2AuthenticateOptions | any,
key: string,
): T {
let base = options[key];
if (options.web && key in options.web) {
base = options.web[key];
}
return base;
}
/**
* Public only for testing
*/
static getAuthorizationUrl(options: WebOptions): string {
let url = options.authorizationBaseUrl + '?client_id=' + options.appId;
url += '&response_type=' + options.responseType;
if (options.redirectUrl) {
url += '&redirect_uri=' + options.redirectUrl;
}
if (options.scope) {
url += '&scope=' + options.scope;
}
url += '&state=' + options.state;
if (options.additionalParameters) {
for (const key in options.additionalParameters) {
url += '&' + key + '=' + options.additionalParameters[key];
}
}
if (options.pkceCodeChallenge) {
url += '&code_challenge=' + options.pkceCodeChallenge;
url += '&code_challenge_method=' + options.pkceCodeChallengeMethod;
}
return encodeURI(url);
}
static getTokenEndpointData(options: WebOptions, code: string): string {
let body = '';
body +=
encodeURIComponent('grant_type') +
'=' +
encodeURIComponent('authorization_code') +
'&';
body +=
encodeURIComponent('client_id') +
'=' +
encodeURIComponent(options.appId) +
'&';
body +=
encodeURIComponent('redirect_uri') +
'=' +
encodeURIComponent(options.redirectUrl) +
'&';
body += encodeURIComponent('code') + '=' + encodeURIComponent(code) + '&';
body +=
encodeURIComponent('code_verifier') +
'=' +
encodeURIComponent(options.pkceCodeVerifier);
return body;
}
static setCodeVerifier(code: string): boolean {
try {
window.sessionStorage.setItem(`I_Capacitor_GenericOAuth2Plugin_PKCE`, code);
return true;
} catch (err) {
return false;
}
}
static clearCodeVerifier(): void {
window.sessionStorage.removeItem(`I_Capacitor_GenericOAuth2Plugin_PKCE`);
}
static getCodeVerifier(): string | null {
return window.sessionStorage.getItem(`I_Capacitor_GenericOAuth2Plugin_PKCE`);
}
/**
* Public only for testing
*/
static getUrlParams(url: string): { [x: string]: string } | undefined {
const urlString = `${url ?? ''}`.trim();
if (urlString.length === 0) {
return;
}
const parsedUrl = new URL(urlString);
if (!parsedUrl.search && !parsedUrl.hash) {
return;
}
let urlParamStr;
if (parsedUrl.search) {
urlParamStr = parsedUrl.search.substr(1);
} else {
urlParamStr = parsedUrl.hash.substr(1);
}
const keyValuePairs: string[] = urlParamStr.split(`&`);
return keyValuePairs.reduce<{ [x: string]: string } | undefined>(
(accumulator, currentValue) => {
const [key, val] = currentValue.split(`=`);
if (key && key.length > 0) {
return {
...accumulator,
[key]: decodeURIComponent(val),
};
}
},
{},
);
}
static randomString(length = 10): string {
const haystack =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
let randomStr;
if (window.crypto) {
let numberArray: Uint32Array = new Uint32Array(length);
window.crypto.getRandomValues(numberArray);
numberArray = numberArray.map(x =>
haystack.charCodeAt(x % haystack.length),
);
const stringArray: string[] = [];
numberArray.forEach(x => {
stringArray.push(haystack.charAt(x % haystack.length));
});
randomStr = stringArray.join('');
} else {
randomStr = '';
for (let i = 0; i < length; i++) {
randomStr += haystack.charAt(
Math.floor(Math.random() * haystack.length),
);
}
}
return randomStr;
}
static async buildWebOptions(
configOptions: OAuth2AuthenticateOptions,
): Promise {
const webOptions = new WebOptions();
webOptions.appId = this.getAppId(configOptions);
webOptions.authorizationBaseUrl = this.getOverwritableValue(
configOptions,
'authorizationBaseUrl',
);
webOptions.responseType = this.getOverwritableValue(
configOptions,
'responseType',
);
if (!webOptions.responseType) {
webOptions.responseType = 'token';
}
webOptions.redirectUrl = this.getOverwritableValue(
configOptions,
'redirectUrl',
);
// controlling parameters
webOptions.resourceUrl = this.getOverwritableValue(
configOptions,
'resourceUrl',
);
webOptions.accessTokenEndpoint = this.getOverwritableValue(
configOptions,
'accessTokenEndpoint',
);
webOptions.pkceEnabled = this.getOverwritableValue(
configOptions,
'pkceEnabled',
);
webOptions.sendCacheControlHeader =
this.getOverwritableValue(configOptions, 'sendCacheControlHeader') ??
webOptions.sendCacheControlHeader;
if (webOptions.pkceEnabled) {
const pkceCode = this.getCodeVerifier();
if (pkceCode) {
webOptions.pkceCodeVerifier = pkceCode;
} else {
webOptions.pkceCodeVerifier = this.randomString(64);
this.setCodeVerifier(webOptions.pkceCodeVerifier);
}
if (CryptoUtils.HAS_SUBTLE_CRYPTO) {
await CryptoUtils.deriveChallenge(webOptions.pkceCodeVerifier).then(
c => {
webOptions.pkceCodeChallenge = c;
webOptions.pkceCodeChallengeMethod = 'S256';
},
);
} else {
webOptions.pkceCodeChallenge = webOptions.pkceCodeVerifier;
webOptions.pkceCodeChallengeMethod = 'plain';
}
}
webOptions.scope = this.getOverwritableValue(configOptions, 'scope');
webOptions.state = this.getOverwritableValue(configOptions, 'state');
if (!webOptions.state || webOptions.state.length === 0) {
webOptions.state = this.randomString(20);
}
const parametersMapHelper = this.getOverwritableValue<{
[key: string]: string;
}>(configOptions, 'additionalParameters');
if (parametersMapHelper) {
webOptions.additionalParameters = {};
for (const key in parametersMapHelper) {
if (key && key.trim().length > 0) {
const value = parametersMapHelper[key];
if (value && value.trim().length > 0) {
webOptions.additionalParameters[key] = value;
}
}
}
}
const headersMapHelper = this.getOverwritableValue<{
[key: string]: string;
}>(configOptions, 'additionalResourceHeaders');
if (headersMapHelper) {
webOptions.additionalResourceHeaders = {};
for (const key in headersMapHelper) {
if (key && key.trim().length > 0) {
const value = headersMapHelper[key];
if (value && value.trim().length > 0) {
webOptions.additionalResourceHeaders[key] = value;
}
}
}
}
webOptions.logsEnabled = this.getOverwritableValue(
configOptions,
'logsEnabled',
);
return webOptions;
}
static buildWindowOptions(
configOptions: OAuth2AuthenticateOptions,
): WebOptions {
const windowOptions = new WebOptions();
if (configOptions.web) {
if (configOptions.web.windowOptions) {
windowOptions.windowOptions = configOptions.web.windowOptions;
}
if (configOptions.web.windowTarget) {
windowOptions.windowTarget = configOptions.web.windowTarget;
}
}
return windowOptions;
}
}
export class CryptoUtils {
static BASE64_CHARS =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
static HAS_SUBTLE_CRYPTO: boolean =
typeof window !== 'undefined' &&
!!(window.crypto as any) &&
!!(window.crypto.subtle as any);
static toUint8Array(str: string): Uint8Array {
const buf = new ArrayBuffer(str.length);
const bufView = new Uint8Array(buf);
for (let i = 0; i < str.length; i++) {
bufView[i] = str.charCodeAt(i);
}
return bufView;
}
static toBase64Url(base64: string): string {
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
static toBase64(bytes: Uint8Array): string {
const len = bytes.length;
let base64 = '';
for (let i = 0; i < len; i += 3) {
base64 += this.BASE64_CHARS[bytes[i] >> 2];
base64 += this.BASE64_CHARS[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)];
base64 +=
this.BASE64_CHARS[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)];
base64 += this.BASE64_CHARS[bytes[i + 2] & 63];
}
if (len % 3 === 2) {
base64 = base64.substring(0, base64.length - 1) + '=';
} else if (len % 3 === 1) {
base64 = base64.substring(0, base64.length - 2) + '==';
}
return base64;
}
static deriveChallenge(codeVerifier: string): Promise {
if (codeVerifier.length < 43 || codeVerifier.length > 128) {
return Promise.reject(new Error('ERR_PKCE_CODE_VERIFIER_INVALID_LENGTH'));
}
if (!CryptoUtils.HAS_SUBTLE_CRYPTO) {
return Promise.reject(new Error('ERR_PKCE_CRYPTO_NOTSUPPORTED'));
}
return new Promise((resolve, reject) => {
crypto.subtle.digest('SHA-256', this.toUint8Array(codeVerifier)).then(
arrayBuffer => {
return resolve(
this.toBase64Url(this.toBase64(new Uint8Array(arrayBuffer))),
);
},
error => reject(error),
);
});
}
}
export class WebOptions {
appId: string;
authorizationBaseUrl: string;
accessTokenEndpoint: string;
resourceUrl: string;
responseType: string;
scope: string;
sendCacheControlHeader = true;
state: string;
redirectUrl: string;
logsEnabled: boolean;
windowOptions: string;
windowTarget = '_blank';
pkceEnabled: boolean;
pkceCodeVerifier: string;
pkceCodeChallenge: string;
pkceCodeChallengeMethod: string;
additionalParameters: { [key: string]: string };
additionalResourceHeaders: { [key: string]: string };
}
================================================
FILE: src/web.ts
================================================
import { WebPlugin } from '@capacitor/core';
import type {
OAuth2AuthenticateOptions,
GenericOAuth2Plugin,
OAuth2RefreshTokenOptions,
ImplicitFlowRedirectOptions,
} from './definitions';
import type { WebOptions } from './web-utils';
import { WebUtils } from './web-utils';
export class GenericOAuth2Web extends WebPlugin implements GenericOAuth2Plugin {
private webOptions: WebOptions;
private windowHandle: Window | null;
private intervalId: number;
private loopCount = 2000;
private intervalLength = 100;
private windowClosedByPlugin: boolean;
/**
* Get a new access token using an existing refresh token.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async refreshToken(_options: OAuth2RefreshTokenOptions): Promise {
return new Promise((_resolve, reject) => {
reject(new Error('Functionality not implemented for PWAs yet'));
});
}
async redirectFlowCodeListener(
options: ImplicitFlowRedirectOptions,
): Promise {
this.webOptions = await WebUtils.buildWebOptions(options);
return new Promise((resolve, reject) => {
const urlParamObj = WebUtils.getUrlParams(options.response_url);
if (urlParamObj) {
const code = urlParamObj.code;
if (code) {
this.getAccessToken(urlParamObj, resolve, reject, code);
} else {
reject(new Error('Oauth Code parameter was not present in url.'));
}
} else {
reject(new Error('Oauth Parameters where not present in url.'));
}
});
}
async authenticate(options: OAuth2AuthenticateOptions): Promise {
const windowOptions = WebUtils.buildWindowOptions(options);
// we open the window first to avoid popups being blocked because of
// the asynchronous buildWebOptions call
this.windowHandle = window.open(
'',
windowOptions.windowTarget,
windowOptions.windowOptions,
);
this.webOptions = await WebUtils.buildWebOptions(options);
return new Promise((resolve, reject) => {
// validate
if (!this.webOptions.appId || this.webOptions.appId.length == 0) {
reject(new Error('ERR_PARAM_NO_APP_ID'));
} else if (
!this.webOptions.authorizationBaseUrl ||
this.webOptions.authorizationBaseUrl.length == 0
) {
reject(new Error('ERR_PARAM_NO_AUTHORIZATION_BASE_URL'));
} else if (
!this.webOptions.redirectUrl ||
this.webOptions.redirectUrl.length == 0
) {
reject(new Error('ERR_PARAM_NO_REDIRECT_URL'));
} else if (
!this.webOptions.responseType ||
this.webOptions.responseType.length == 0
) {
reject(new Error('ERR_PARAM_NO_RESPONSE_TYPE'));
} else {
// init internal control params
let loopCount = this.loopCount;
this.windowClosedByPlugin = false;
// open window
const authorizationUrl = WebUtils.getAuthorizationUrl(this.webOptions);
if (this.webOptions.logsEnabled) {
this.doLog('Authorization url: ' + authorizationUrl);
}
if (this.windowHandle) {
this.windowHandle.location.href = authorizationUrl;
}
// wait for redirect and resolve the
this.intervalId = window.setInterval(() => {
if (loopCount-- < 0) {
this.closeWindow();
} else if (this.windowHandle?.closed && !this.windowClosedByPlugin) {
window.clearInterval(this.intervalId);
reject(new Error('USER_CANCELLED'));
} else {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
let href: string = undefined!;
try {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href = this.windowHandle!.location.href!;
} catch (ignore) {
// ignore DOMException: Blocked a frame with origin "http://localhost:4200" from accessing a cross-origin frame.
}
if (
href != null &&
href.indexOf(this.webOptions.redirectUrl) >= 0
) {
if (this.webOptions.logsEnabled) {
this.doLog('Url from Provider: ' + href);
}
const authorizationRedirectUrlParamObj =
WebUtils.getUrlParams(href);
if (authorizationRedirectUrlParamObj) {
if (this.webOptions.logsEnabled) {
this.doLog(
'Authorization response:',
authorizationRedirectUrlParamObj,
);
}
window.clearInterval(this.intervalId);
// check state
if (
authorizationRedirectUrlParamObj.state ===
this.webOptions.state
) {
if (this.webOptions.accessTokenEndpoint) {
const authorizationCode =
authorizationRedirectUrlParamObj.code;
if (authorizationCode) {
this.getAccessToken(
authorizationRedirectUrlParamObj,
resolve,
reject,
authorizationCode,
);
} else {
reject(new Error('ERR_NO_AUTHORIZATION_CODE'));
}
this.closeWindow();
} else {
// if no accessTokenEndpoint exists request the resource
this.requestResource(
authorizationRedirectUrlParamObj.access_token,
resolve,
reject,
authorizationRedirectUrlParamObj,
);
}
} else {
if (this.webOptions.logsEnabled) {
this.doLog(
'State from web options: ' + this.webOptions.state,
);
this.doLog(
'State returned from provider: ' +
authorizationRedirectUrlParamObj.state,
);
}
reject(new Error('ERR_STATES_NOT_MATCH'));
this.closeWindow();
}
}
// this is no error no else clause required
}
}
}, this.intervalLength);
}
});
}
private readonly MSG_RETURNED_TO_JS = 'Returned to JS:';
private getAccessToken(
authorizationRedirectUrlParamObj: { [p: string]: string } | undefined,
resolve: (value: any) => void,
reject: (reason?: any) => void,
authorizationCode: string,
) {
const tokenRequest = new XMLHttpRequest();
tokenRequest.onload = () => {
WebUtils.clearCodeVerifier();
if (tokenRequest.status === 200) {
const accessTokenResponse = JSON.parse(tokenRequest.response);
if (this.webOptions.logsEnabled) {
this.doLog('Access token response:', accessTokenResponse);
}
this.requestResource(
accessTokenResponse.access_token,
resolve,
reject,
authorizationRedirectUrlParamObj,
accessTokenResponse,
);
}
};
tokenRequest.onerror = () => {
this.doLog(
'ERR_GENERAL: See client logs. It might be CORS. Status text: ' +
tokenRequest.statusText,
);
reject(new Error('ERR_GENERAL'));
};
tokenRequest.open('POST', this.webOptions.accessTokenEndpoint, true);
tokenRequest.setRequestHeader('accept', 'application/json');
if (this.webOptions.sendCacheControlHeader) {
tokenRequest.setRequestHeader(
'cache-control',
'no-cache',
);
}
tokenRequest.setRequestHeader(
'content-type',
'application/x-www-form-urlencoded',
);
tokenRequest.send(
WebUtils.getTokenEndpointData(this.webOptions, authorizationCode),
);
}
private requestResource(
accessToken: string,
resolve: any,
reject: (reason?: any) => void,
authorizationResponse: any,
accessTokenResponse: any = null,
) {
if (this.webOptions.resourceUrl) {
const logsEnabled = this.webOptions.logsEnabled;
if (logsEnabled) {
this.doLog('Resource url: ' + this.webOptions.resourceUrl);
}
if (accessToken) {
if (logsEnabled) {
this.doLog('Access token:', accessToken);
}
const self = this;
const request = new XMLHttpRequest();
request.onload = function () {
if (this.status === 200) {
const resp = JSON.parse(this.response);
if (logsEnabled) {
self.doLog('Resource response:', resp);
}
if (resp) {
self.assignResponses(
resp,
accessToken,
authorizationResponse,
accessTokenResponse,
);
}
if (logsEnabled) {
self.doLog(self.MSG_RETURNED_TO_JS, resp);
}
resolve(resp);
} else {
reject(new Error(this.statusText));
}
self.closeWindow();
};
request.onerror = function () {
if (logsEnabled) {
self.doLog('ERR_GENERAL: ' + this.statusText);
}
reject(new Error('ERR_GENERAL'));
self.closeWindow();
};
request.open('GET', this.webOptions.resourceUrl, true);
request.setRequestHeader('Authorization', `Bearer ${accessToken}`);
if (this.webOptions.additionalResourceHeaders) {
for (const key in this.webOptions.additionalResourceHeaders) {
request.setRequestHeader(
key,
this.webOptions.additionalResourceHeaders[key],
);
}
}
request.send();
} else {
if (logsEnabled) {
this.doLog(
'No accessToken was provided although you configured a resourceUrl. Remove the resourceUrl from the config.',
);
}
reject(new Error('ERR_NO_ACCESS_TOKEN'));
this.closeWindow();
}
} else {
// if no resource url exists just return the accessToken response
const resp = {};
this.assignResponses(
resp,
accessToken,
authorizationResponse,
accessTokenResponse,
);
if (this.webOptions.logsEnabled) {
this.doLog(this.MSG_RETURNED_TO_JS, resp);
}
resolve(resp);
this.closeWindow();
}
}
assignResponses(
resp: any,
accessToken: string,
authorizationResponse: any,
accessTokenResponse: any = null,
): void {
// #154
if (authorizationResponse) {
resp['authorization_response'] = authorizationResponse;
}
if (accessTokenResponse) {
resp['access_token_response'] = accessTokenResponse;
}
resp['access_token'] = accessToken;
}
async logout(options: OAuth2AuthenticateOptions): Promise {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
return new Promise((resolve, _reject) => {
localStorage.removeItem(WebUtils.getAppId(options));
resolve(true);
});
}
private closeWindow() {
window.clearInterval(this.intervalId);
// #164 if the provider's login page is opened in the same tab or window it must not be closed
// if (this.webOptions.windowTarget !== "_self") {
// this.windowHandle?.close();
// }
this.windowHandle?.close();
this.windowClosedByPlugin = true;
}
private doLog(msg: string, obj: any = null) {
console.log('I/Capacitor/GenericOAuth2Plugin: ' + msg, obj);
}
}
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"allowUnreachableCode": false,
"declaration": true,
"esModuleInterop": true,
"inlineSources": true,
"lib": ["dom", "es2017"],
"module": "esnext",
"moduleResolution": "node",
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"outDir": "dist/esm",
"pretty": true,
"sourceMap": true,
"strict": true,
"strictPropertyInitialization": false,
"target": "es2017"
},
"files": ["src/index.ts"]
}