├── .codebuild.json
├── .gitignore
├── .travis.yml
├── LICENSE
├── Makefile
├── README.md
├── apps
├── authorize
│ ├── Makefile
│ ├── rebar.config
│ ├── rebar.lock
│ ├── src
│ │ ├── authorize.app.src
│ │ └── authorize.erl
│ └── test
│ │ ├── authorize_SUITE.erl
│ │ ├── signup.json
│ │ └── tests.config
├── client
│ ├── Makefile
│ ├── rebar.config
│ ├── rebar.lock
│ ├── src
│ │ ├── client.app.src
│ │ └── client.erl
│ └── test
│ │ ├── client_SUITE.erl
│ │ └── tests.config
├── github
│ └── src
│ │ ├── github.app.src
│ │ └── github.erl
├── signin
│ └── src
│ │ ├── signin.app.src
│ │ └── signin.erl
└── token
│ ├── Makefile
│ ├── rebar.config
│ ├── rebar.lock
│ ├── src
│ ├── token.app.src
│ └── token.erl
│ └── test
│ ├── tests.config
│ └── token_SUITE.erl
├── buildspec.yml
├── cleanspec.yml
├── cloud
├── cdk.json
├── package-lock.json
├── package.json
├── src
│ ├── app.ts
│ ├── auth.ts
│ ├── client.ts
│ ├── permit.ts
│ ├── storage.ts
│ └── token.ts
├── tsconfig.json
└── tslint.json
├── doc
├── install.md
├── restapi.yml
└── security.md
├── js
├── account
│ ├── .env
│ ├── .eslintrc
│ ├── Makefile
│ ├── jsconfig.json
│ ├── package-lock.json
│ ├── package.json
│ ├── public
│ │ ├── favicon.ico
│ │ └── index.html
│ └── src
│ │ ├── app.css
│ │ ├── app.js
│ │ ├── components
│ │ ├── Account
│ │ │ ├── Account.js
│ │ │ ├── Header.js
│ │ │ └── index.js
│ │ ├── Issue
│ │ │ ├── Issue.js
│ │ │ └── index.js
│ │ ├── NewApp
│ │ │ ├── KeyPair
│ │ │ │ ├── KeyPair.js
│ │ │ │ └── index.js
│ │ │ ├── NewApp.js
│ │ │ ├── Registrar
│ │ │ │ ├── Endpoint.js
│ │ │ │ ├── Identity.js
│ │ │ │ ├── Registrar.js
│ │ │ │ ├── Security.js
│ │ │ │ └── index.js
│ │ │ └── index.js
│ │ └── Registry
│ │ │ ├── Registry.js
│ │ │ ├── RegistryList.js
│ │ │ ├── RegistryNone.js
│ │ │ └── index.js
│ │ └── index.js
└── signin
│ ├── .env
│ ├── .eslintrc
│ ├── Makefile
│ ├── jsconfig.json
│ ├── package-lock.json
│ ├── package.json
│ ├── public
│ ├── env.js
│ ├── favicon.ico
│ └── index.html
│ └── src
│ ├── app.css
│ ├── app.js
│ ├── components
│ ├── AccessKey.js
│ ├── Dialog.js
│ ├── GitHub.js
│ ├── KeyPair.js
│ ├── SecretKey.js
│ ├── SecretRecover.js
│ ├── SecretReset.js
│ ├── SignIn.js
│ └── SignUp.js
│ └── index.js
├── libs
└── oauth2
│ ├── Makefile
│ ├── beam.mk
│ ├── include
│ └── oauth2.hrl
│ ├── rebar.config
│ ├── rebar.lock
│ ├── src
│ ├── oauth2.app.src
│ ├── oauth2.erl
│ ├── oauth2_access.erl
│ ├── oauth2_authorize.erl
│ ├── oauth2_client.erl
│ ├── oauth2_codec.erl
│ └── oauth2_email.erl
│ └── test
│ ├── oauth2_FIXTURES.erl
│ ├── oauth2_SUITE.erl
│ ├── oauth2_client_SUITE.erl
│ ├── oauth2_codec_SUITE.erl
│ └── tests.config
└── serverless.mk
/.codebuild.json:
--------------------------------------------------------------------------------
1 | {
2 | "image": "code-build/serverless",
3 | "approver": ["fogfish"]
4 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | _build/
2 | cdk.out/
3 | cdk.context.json
4 | log/
5 | rebar3
6 | *.sublime-*
7 | *.dump
8 | *.beam
9 |
10 | ##
11 | ## release artifacts
12 | *.tar.gz
13 | *.bundle
14 |
15 | ##
16 | ## created dynamically by rebar3/relx
17 | ## it shall be ignored otherwise release is dirty
18 | relx.config
19 |
20 | ##
21 | ## used by ux
22 | .env.production
23 | *.map
24 | node_modules/
25 | build/
26 | .DS_Store
27 |
28 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | dist: bionic
2 |
3 | language: erlang
4 | otp_release:
5 | - 22.1
6 |
7 | script:
8 | - make test
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | APPENDIX: How to apply the Apache License to your work.
180 |
181 | To apply the Apache License to your work, attach the following
182 | boilerplate notice, with the fields enclosed by brackets "[]"
183 | replaced with your own identifying information. (Don't include
184 | the brackets!) The text should be enclosed in the appropriate
185 | comment syntax for the file format. We also recommend that a
186 | file or class name and description of purpose be included on the
187 | same "printed page" as the copyright notice for easier
188 | identification within third-party archives.
189 |
190 | Copyright 2017 Dmitry Kolesnikov
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 | you may not use this file except in compliance with the License.
194 | You may obtain a copy of the License at
195 |
196 | http://www.apache.org/licenses/LICENSE-2.0
197 |
198 | Unless required by applicable law or agreed to in writing, software
199 | distributed under the License is distributed on an "AS IS" BASIS,
200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 | See the License for the specific language governing permissions and
202 | limitations under the License.
203 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: deps
2 |
3 | SRC = \
4 | apps/authorize \
5 | apps/token \
6 | apps/client \
7 | js/signin \
8 | js/account
9 |
10 |
11 | deps:
12 | @npm --prefix cloud install
13 | @for I in $(SRC) ; do $(MAKE) -C $$I deps ; done
14 |
15 | test:
16 | @npm --prefix cloud run lint
17 | @for I in $(SRC) ; do $(MAKE) -C $$I test ; done
18 |
19 | dist:
20 | @for I in $(SRC) ; do $(MAKE) -C $$I dist VSN=${BUILD_RELEASE}; done
21 |
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # OAuth2
2 |
3 | The microservice based implementation of OAuth 2.0 Authorization Framework, [RFC 6749](https://tools.ietf.org/html/rfc6749). It provides an out-of-the-box, cross-platform solution for identity management. This appliance implements an automated and immutable deployment of OAuth 2.0 framework and its cloud resources using Infrastructure-as-a-Code.
4 |
5 | [](http://travis-ci.org/fogfish/oauth2) [](https://github.com/fogfish/oauth2/releases/latest) [](https://coveralls.io/github/fogfish/oauth2?branch=master)
6 |
7 | ## Key Features and Functionality
8 |
9 | The appliance architecture and design reflect the principles of incremental scalability, decentralization and fault tolerance. The appliance targets no configuration experience for cloud operation and deployment.
10 |
11 | **OAuth 2.0 grants flow**: It supports out of the box grants defined by RFC 6749: Authorization code grant, Implicit grant, Client credentials grant, Resource owner password credentials grant and Refresh Token grant.
12 |
13 | **Required client identity**: [RFC 6749](https://tools.ietf.org/html/rfc6749#section-3.2.1) discusses about client authentication. This implementation requires HTTP basic digest schema to identity confidential clients and demands `client_id` parameter to identify public clients when sending requests to service endpoints.
14 |
15 | **Account settings dashboard**: provides reference implementation of account setting dashboard using react.js
16 |
17 | **Account federation** supports an integration with 3rd party services such GitHub
18 |
19 |
20 | ## Getting Started
21 |
22 | The appliance supplies pre-built releases for Linux/x86_64 and Docker platforms. Instructions for using these binaries are on the [GitHub releases page](https://github.com/fogfish/oauth2/releases).
23 |
24 | Build the latest version of authorization server from the `master` branch. The build process requires [Erlang/OTP](http://www.erlang.org/downloads) version 19.0 or later. All development, including new features and bug fixes, take place on the `master` branch using forking and pull requests as described in contribution guidelines.
25 |
26 | Build toolchain
27 |
28 | ```bash
29 | npm install -g typescript
30 | npm install -g aws-cdk
31 | npm install -g ts-node
32 | ```
33 |
34 | ### Running authorization server
35 |
36 | The easiest way to run the appliance is with the Docker container. The option is viable only if you have configured Docker development environment on your platform. Use the latest [release version](https://github.com/fogfish/oauth2/releases):
37 |
38 | User either
39 |
40 | ```
41 | make dist-up
42 | ```
43 |
44 | or
45 |
46 | ```
47 | docker-compose up
48 | ```
49 |
50 | This starts a local instances of required backing services (e.g. DynamoDB, Redis, etc), authorization service itself and exposed OAuth 2.0 services using REST API on port 8080. By default, it is bound to `localhost` on Mac OS and Linux. If you're using a different platform, please check your Docker configuration.
51 |
52 | Open `http://localhost:8080/oauth2/developer` in your web browser to manage accounts and integrate OAuth 2.0 clients.
53 |
54 |
55 | ## Next Steps
56 |
57 | * study [The OAuth 2.0 Authorization Framework](https://tools.ietf.org/html/rfc6749) and its authorization flows.
58 | * [installation and configuration guidelines](docs/install.md) to the cloud for production operation.
59 |
60 |
61 | ## Contribution
62 |
63 | OAuth 2.0 is Apache 2.0 licensed and accepts contributions via GitHub pull requests:
64 |
65 | * Fork the repository on GitHub
66 | * Read build instructions
67 | * Make a pull request
68 |
69 | The build process requires [Erlang/OTP](http://www.erlang.org/downloads) version 19.0 or later and essential build tools.
70 |
71 | **Build** and **run** authorization service in your development console. The following command boots Erlang virtual machine and opens Erlang shell.
72 |
73 | ```
74 | git clone https://github.com/fogfish/oauth2
75 | cd oauth2
76 | make
77 | make run
78 | ```
79 |
80 | The development of authorization server requires ensemble of **backing services** (e.g. DynamoDB, Redis, etc).
81 |
82 | ```
83 | docker-compose -f rel/service.yaml up
84 | ```
85 |
86 | Now you are able to start oauth2 is **debug** mode. You shall be able to use OAuth 2.0 REST API once you launch application:
87 |
88 | ```erlang
89 | oauth2:start().
90 | ```
91 |
92 | **Package** the application into docker container.
93 |
94 | ```bash
95 | make clean && make && make release && make docker
96 | ```
97 | The archive `oauth2-{vsn}.{arch}.{plat}.bundle` contains both a Erlang VM, all required dependencies and the application.
98 |
99 |
100 | ### commit message
101 |
102 | The commit message helps us to write a good release note, speed-up review process. The message should address two question what changed and why. The project follows the template defined by chapter [Contributing to a Project](http://git-scm.com/book/ch5-2.html) of Git book.
103 |
104 | >
105 | > Short (50 chars or less) summary of changes
106 | >
107 | > More detailed explanatory text, if necessary. Wrap it to about 72 characters or so. In some contexts, the first line is treated as the subject of an email and the rest of the text as the body. The blank line separating the summary from the body is critical (unless you omit the body entirely); tools like rebase can get confused if you run the two together.
108 | >
109 | > Further paragraphs come after blank lines.
110 | >
111 | > Bullet points are okay, too
112 | >
113 | > Typically a hyphen or asterisk is used for the bullet, preceded by a single space, with blank lines in between, but conventions vary here
114 | >
115 | >
116 |
117 | ### bugs
118 |
119 | If you experience any issues with OAuth 2.0 appliance, please let us know via [GitHub issues](https://github.com/fogfish/oauth2/issue). We appreciate detailed and accurate reports that help us to identity and replicate the issue.
120 |
121 | * **Specify** the configuration of your environment. Include which operating system you use and the versions of runtime environments.
122 |
123 | * **Attach** logs, screenshots and exceptions, in possible.
124 |
125 | * **Reveal** the steps you took to reproduce the problem.
126 |
127 |
128 | ### security issues
129 |
130 | If you discover any security related issues, please [email](mailto:dmkolesnikov@gmail.com) instead of using the issue tracker.
131 |
132 | ## Changelog
133 |
134 | The appliance uses [semantic versions](http://semver.org) to identify stable releases.
135 |
136 | * 0.3.0 - support for https
137 | * 0.2.0 - production ready release candidate
138 | * 0.0.0 - initial release for testing purpose
139 |
140 | ## References
141 |
142 | 1. [The OAuth 2.0 Authorization Framework](https://tools.ietf.org/html/rfc6749)
143 | 1. [OAuth 2.0 Token Introspection](https://tools.ietf.org/html/rfc7662)
144 | 1. [JSON Web Token (JWT)](https://tools.ietf.org/html/rfc7519)
145 |
146 |
147 |
148 | ## License
149 |
150 | Copyright 2017 Dmitry Kolesnikov
151 |
152 | 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 http://www.apache.org/licenses/LICENSE-2.0.
153 |
154 | 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.
155 |
--------------------------------------------------------------------------------
/apps/authorize/Makefile:
--------------------------------------------------------------------------------
1 | APP = authorize
2 | EVENT ?= test/signup.json
3 |
4 | include ../../serverless.mk
5 |
--------------------------------------------------------------------------------
/apps/authorize/rebar.config:
--------------------------------------------------------------------------------
1 | {erl_opts, []}.
2 |
3 | {deps, [
4 | {serverless, ".*",
5 | {git, "https://github.com/fogfish/serverless", {branch, master}}
6 | }
7 |
8 | , {feta, ".*",
9 | {git, "https://github.com/fogfish/feta", {branch, master}}
10 | }
11 |
12 | , {oauth2, {path, "../../libs/oauth2"}}
13 | ]}.
14 |
15 | {profiles, [
16 | {test, [{deps, [meck]}]}
17 | ]}.
18 |
19 | {plugins, [
20 | rebar3_path_deps
21 | ]}.
22 |
23 | %%
24 | %%
25 | {escript_main_app , authorize}.
26 | {escript_emu_args , "%%! -smp -sbt ts +A10 +K true\n"}.
27 | {escript_incl_apps , [serverless]}.
28 |
--------------------------------------------------------------------------------
/apps/authorize/rebar.lock:
--------------------------------------------------------------------------------
1 | {"1.1.0",
2 | [{<<"base16">>,{pkg,<<"base16">>,<<"1.0.0">>},2},
3 | {<<"base64url">>,{pkg,<<"base64url">>,<<"0.0.1">>},3},
4 | {<<"certifi">>,{pkg,<<"certifi">>,<<"2.5.1">>},3},
5 | {<<"datum">>,{pkg,<<"datum">>,<<"4.6.1">>},1},
6 | {<<"ddb">>,
7 | {git,"https://github.com/fogfish/ddb",
8 | {ref,"8486e553bd96e2646a1cf27e33c345c2f22ec1d7"}},
9 | 1},
10 | {<<"eini">>,{pkg,<<"eini">>,<<"1.2.6">>},2},
11 | {<<"erlcloud">>,{pkg,<<"erlcloud">>,<<"3.2.7">>},1},
12 | {<<"feta">>,
13 | {git,"https://github.com/fogfish/feta",
14 | {ref,"c5d251b3f995b96afd5e8ec7da61516842aa658c"}},
15 | 0},
16 | {<<"hackney">>,{pkg,<<"hackney">>,<<"1.15.1">>},2},
17 | {<<"hash">>,
18 | {git,"https://github.com/fogfish/hash",
19 | {ref,"a1b9101189e115b4eabbe941639f3c626614e986"}},
20 | 2},
21 | {<<"idna">>,{pkg,<<"idna">>,<<"6.0.0">>},3},
22 | {<<"jsx">>,{pkg,<<"jsx">>,<<"2.10.0">>},1},
23 | {<<"jwt">>,{pkg,<<"jwt">>,<<"0.1.9">>},2},
24 | {<<"lhttpc">>,{pkg,<<"lhttpc">>,<<"1.6.2">>},2},
25 | {<<"m_http">>,{pkg,<<"m_http">>,<<"0.2.2">>},1},
26 | {<<"meck">>,{pkg,<<"meck">>,<<"0.8.13">>},3},
27 | {<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},3},
28 | {<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.2.0">>},3},
29 | {<<"oauth2">>,
30 | {path,"../../libs/oauth2",{mtime,<<"2019-12-24T13:38:23Z">>}},
31 | 0},
32 | {<<"parse_trans">>,{pkg,<<"parse_trans">>,<<"3.3.0">>},4},
33 | {<<"permit">>,
34 | {git,"https://github.com/fogfish/permit",
35 | {ref,"7ace1307d2dd312aa140f3710efdbbba75c8f451"}},
36 | 1},
37 | {<<"pipe">>,{pkg,<<"pipes">>,<<"2.0.1">>},1},
38 | {<<"pipes">>,{pkg,<<"pipes">>,<<"2.0.1">>},2},
39 | {<<"pns">>,
40 | {git,"https://github.com/fogfish/pns",
41 | {ref,"f9b37630d0a8797063a2a443b42f14353ad858c8"}},
42 | 3},
43 | {<<"pts">>,
44 | {git,"https://github.com/fogfish/pts",
45 | {ref,"f98c594f1e596f81b5e54c9791bf69568327fba2"}},
46 | 2},
47 | {<<"serverless">>,
48 | {git,"https://github.com/fogfish/serverless",
49 | {ref,"c804e510e9c473941b1b457ba175f537f1939f39"}},
50 | 0},
51 | {<<"ssl_verify_fun">>,{pkg,<<"ssl_verify_fun">>,<<"1.1.4">>},3},
52 | {<<"uid">>,{pkg,<<"uid">>,<<"1.3.4">>},2},
53 | {<<"unicode_util_compat">>,{pkg,<<"unicode_util_compat">>,<<"0.4.1">>},4}]}.
54 | [
55 | {pkg_hash,[
56 | {<<"base16">>, <<"283644E2B21BD5915ACB7178BED7851FB07C6E5749B8FAD68A53C501092176D9">>},
57 | {<<"base64url">>, <<"36A90125F5948E3AFD7BE97662A1504B934DD5DAC78451CA6E9ABF85A10286BE">>},
58 | {<<"certifi">>, <<"867CE347F7C7D78563450A18A6A28A8090331E77FA02380B4A21962A65D36EE5">>},
59 | {<<"datum">>, <<"93B131203A60CFEA9FFFF6435A50DC24239F689DFEBB76E6AECF6CE689EFE8F4">>},
60 | {<<"eini">>, <<"DFFA48476FD89FB6E41CEEA0ADFA1BC6E7862CCD6584417442F8BB37E5D34715">>},
61 | {<<"erlcloud">>, <<"85A947B7F53C58A884590423437670679B61AF03536D7FB583BEEB2B5054D0F2">>},
62 | {<<"hackney">>, <<"9F8F471C844B8CE395F7B6D8398139E26DDCA9EBC171A8B91342EE15A19963F4">>},
63 | {<<"idna">>, <<"689C46CBCDF3524C44D5F3DDE8001F364CD7608A99556D8FBD8239A5798D4C10">>},
64 | {<<"jsx">>, <<"77760560D6AC2B8C51FD4C980E9E19B784016AA70BE354CE746472C33BEB0B1C">>},
65 | {<<"jwt">>, <<"F5687B0168AE3AA1130E0068C38A7D256E622789BF4724578ADF99A7CE5531DC">>},
66 | {<<"lhttpc">>, <<"044F16F0018C7AA7E945E9E9406C7F6035E0B8BC08BF77B00C78CE260E1071E3">>},
67 | {<<"m_http">>, <<"A869974B4651658786BCE18F2019E9B8DA5E5F8AEFD9A5CCE1291B941F05A2F9">>},
68 | {<<"meck">>, <<"FFEDB39F99B0B99703B8601C6F17C7F76313EE12DE6B646E671E3188401F7866">>},
69 | {<<"metrics">>, <<"25F094DEA2CDA98213CECC3AEFF09E940299D950904393B2A29D191C346A8486">>},
70 | {<<"mimerl">>, <<"67E2D3F571088D5CFD3E550C383094B47159F3EEE8FFA08E64106CDF5E981BE3">>},
71 | {<<"parse_trans">>, <<"09765507A3C7590A784615CFD421D101AEC25098D50B89D7AA1D66646BC571C1">>},
72 | {<<"pipe">>, <<"A2B56796C63690ED0E78BB77BB389AF250BD70AFA15A6869369DBDC11087D68F">>},
73 | {<<"pipes">>, <<"A2B56796C63690ED0E78BB77BB389AF250BD70AFA15A6869369DBDC11087D68F">>},
74 | {<<"ssl_verify_fun">>, <<"F0EAFFF810D2041E93F915EF59899C923F4568F4585904D010387ED74988E77B">>},
75 | {<<"uid">>, <<"42E30E22908E8E2FAA6227E9C261F1954CB540BE3C5A139E112369AE6CC451FC">>},
76 | {<<"unicode_util_compat">>, <<"D869E4C68901DD9531385BB0C8C40444EBF624E60B6962D95952775CAC5E90CD">>}]}
77 | ].
78 |
--------------------------------------------------------------------------------
/apps/authorize/src/authorize.app.src:
--------------------------------------------------------------------------------
1 | {application, authorize,
2 | [
3 | {description, "oauth2 authorize api"},
4 | {vsn, "0.0.0"},
5 | {modules, []},
6 | {registered, []},
7 | {applications,[
8 | kernel
9 | , stdlib
10 | , feta
11 | , oauth2
12 | ]},
13 | {env, []}
14 | ]
15 | }.
16 |
--------------------------------------------------------------------------------
/apps/authorize/src/authorize.erl:
--------------------------------------------------------------------------------
1 | -module(authorize).
2 |
3 | -compile({parse_transform, category}).
4 | -compile({parse_transform, generic}).
5 |
6 | -export([main/1]).
7 |
8 | %%
9 | %%
10 | main(Opts) ->
11 | {ok, _} = application:ensure_all_started(?MODULE),
12 | serverless:spawn(fun api/1, Opts).
13 |
14 | %%
15 | %%
16 | -spec api(map()) -> datum:either(map()).
17 |
18 | api(#{
19 | <<"httpMethod">> := <<"POST">>
20 | } = Json) ->
21 | case dispatch(Json) of
22 | {ok, Redirect} ->
23 | serverless_api:return({302, #{<<"Location">> => uri:s(Redirect)}, <<>>});
24 | {error, _} = Error ->
25 | serverless_api:return(Error)
26 | end;
27 | api(Json) ->
28 | serverless:warning(Json),
29 | {error, not_supported}.
30 |
31 | %%
32 | %%
33 | dispatch(#{
34 | <<"path">> := <<"/oauth2/signin">>,
35 | <<"headers">> := #{
36 | <<"content-type">> := <<"application/x-www-form-urlencoded", _/binary>>
37 | } = Headers,
38 | <<"body">> := Request
39 | }) ->
40 | oauth2:signin(Headers, Request);
41 |
42 | dispatch(#{
43 | <<"path">> := <<"/oauth2/signup">>,
44 | <<"headers">> := #{
45 | <<"content-type">> := <<"application/x-www-form-urlencoded", _/binary>>
46 | } = Headers,
47 | <<"body">> := Request
48 | }) ->
49 | oauth2:signup(Headers, Request);
50 |
51 | dispatch(#{
52 | <<"path">> := <<"/oauth2/password">>,
53 | <<"headers">> := #{
54 | <<"content-type">> := <<"application/x-www-form-urlencoded", _/binary>>
55 | } = Headers,
56 | <<"body">> := Request
57 | }) ->
58 | oauth2:password(Headers, Request);
59 |
60 | dispatch(Json) ->
61 | serverless:warning(Json),
62 | {error, not_supported}.
63 |
--------------------------------------------------------------------------------
/apps/authorize/test/authorize_SUITE.erl:
--------------------------------------------------------------------------------
1 | -module(authorize_SUITE).
2 |
3 | -export([all/0, init_per_suite/1, end_per_suite/1]).
4 | -compile(export_all).
5 |
6 | all() ->
7 | [Test || {Test, NAry} <- ?MODULE:module_info(exports),
8 | Test =/= module_info,
9 | Test =/= init_per_suite,
10 | Test =/= end_per_suite,
11 | NAry =:= 1
12 | ].
13 |
14 | %%%----------------------------------------------------------------------------
15 | %%%
16 | %%% init
17 | %%%
18 | %%%----------------------------------------------------------------------------
19 | init_per_suite(Config) ->
20 | os:putenv("PERMIT_ISSUER", "https://example.com"),
21 | os:putenv("PERMIT_AUDIENCE", "suite"),
22 | os:putenv("PERMIT_CLAIMS", "read=true&write=true"),
23 | {ok, _} = application:ensure_all_started(oauth2),
24 | Config.
25 |
26 |
27 | end_per_suite(_Config) ->
28 | ok.
29 |
30 | %%
31 | %%
32 | signup_public_client(_) ->
33 | #{
34 | statusCode := 302
35 | , headers := #{
36 | <<"Location">> := Location
37 | }
38 | , body := _
39 | } = serverless:mock(authorize,
40 | #{
41 | <<"httpMethod">> => <<"POST">>
42 | , <<"path">> => <<"/oauth2/signup">>
43 | , <<"headers">> => #{
44 | <<"content-type">> => <<"application/x-www-form-urlencoded">>
45 | }
46 | , <<"body">> => <<"response_type=code&client_id=account@oauth2&access=joe@org&secret=secret">>
47 | }
48 | ),
49 | IDP = base64url:encode(crypto:hash(md5, <<"joe@org">>)),
50 | {ok, #{
51 | <<"iss">> := <<"https://example.com">>
52 | , <<"aud">> := <<"oauth2">>
53 | , <<"idp">> := IDP
54 | , <<"sub">> := {iri, IDP, <<"joe@org">>}
55 | , <<"exp">> := _
56 | , <<"tji">> := _
57 | }} = permit:validate( uri:q(<<"code">>, undefined, uri:new(Location)) ).
58 |
59 | %%
60 | %%
61 | signin_public_client(_) ->
62 | #{
63 | statusCode := 302
64 | , headers := #{
65 | <<"Location">> := Location
66 | }
67 | , body := _
68 | } = serverless:mock(authorize,
69 | #{
70 | <<"httpMethod">> => <<"POST">>
71 | , <<"path">> => <<"/oauth2/signin">>
72 | , <<"headers">> => #{
73 | <<"content-type">> => <<"application/x-www-form-urlencoded">>
74 | }
75 | , <<"body">> => <<"response_type=code&client_id=account@oauth2&access=joe@org&secret=secret">>
76 | }
77 | ),
78 | IDP = base64url:encode(crypto:hash(md5, <<"joe@org">>)),
79 | {ok, #{
80 | <<"iss">> := <<"https://example.com">>
81 | , <<"aud">> := <<"oauth2">>
82 | , <<"idp">> := IDP
83 | , <<"sub">> := {iri, IDP, <<"joe@org">>}
84 | , <<"exp">> := _
85 | , <<"tji">> := _
86 | }} = permit:validate( uri:q(<<"code">>, undefined, uri:new(Location)) ).
87 |
--------------------------------------------------------------------------------
/apps/authorize/test/signup.json:
--------------------------------------------------------------------------------
1 | {
2 | "httpMethod": "POST",
3 | "path": "/oauth2/signup",
4 | "headers": {
5 | "content-type": "application/x-www-form-urlencoded"
6 | },
7 | "body": "response_type=code&client_id=account@oauth2&access=access@org&secret=secret"
8 | }
--------------------------------------------------------------------------------
/apps/authorize/test/tests.config:
--------------------------------------------------------------------------------
1 | {suites, ".", all}.
2 |
--------------------------------------------------------------------------------
/apps/client/Makefile:
--------------------------------------------------------------------------------
1 | APP = client
2 | EVENT ?=
3 |
4 | include ../../serverless.mk
5 |
--------------------------------------------------------------------------------
/apps/client/rebar.config:
--------------------------------------------------------------------------------
1 | {erl_opts, []}.
2 |
3 | {deps, [
4 | {serverless, ".*",
5 | {git, "https://github.com/fogfish/serverless", {branch, master}}
6 | }
7 |
8 | , {feta, ".*",
9 | {git, "https://github.com/fogfish/feta", {branch, master}}
10 | }
11 |
12 | , {oauth2, {path, "../../libs/oauth2"}}
13 | ]}.
14 |
15 | {profiles, [
16 | {test, [{deps, [meck]}]}
17 | ]}.
18 |
19 | {plugins, [
20 | rebar3_path_deps
21 | ]}.
22 |
23 | %%
24 | %%
25 | {escript_main_app , client}.
26 | {escript_emu_args , "%%! -smp -sbt ts +A10 +K true\n"}.
27 | {escript_incl_apps , [serverless]}.
28 |
--------------------------------------------------------------------------------
/apps/client/rebar.lock:
--------------------------------------------------------------------------------
1 | {"1.1.0",
2 | [{<<"base16">>,{pkg,<<"base16">>,<<"1.0.0">>},2},
3 | {<<"base64url">>,{pkg,<<"base64url">>,<<"0.0.1">>},3},
4 | {<<"certifi">>,{pkg,<<"certifi">>,<<"2.5.1">>},3},
5 | {<<"datum">>,{pkg,<<"datum">>,<<"4.6.1">>},1},
6 | {<<"ddb">>,
7 | {git,"https://github.com/fogfish/ddb",
8 | {ref,"8486e553bd96e2646a1cf27e33c345c2f22ec1d7"}},
9 | 1},
10 | {<<"eini">>,{pkg,<<"eini">>,<<"1.2.6">>},2},
11 | {<<"erlcloud">>,{pkg,<<"erlcloud">>,<<"3.2.7">>},1},
12 | {<<"feta">>,
13 | {git,"https://github.com/fogfish/feta",
14 | {ref,"c5d251b3f995b96afd5e8ec7da61516842aa658c"}},
15 | 0},
16 | {<<"hackney">>,{pkg,<<"hackney">>,<<"1.15.1">>},2},
17 | {<<"hash">>,
18 | {git,"https://github.com/fogfish/hash",
19 | {ref,"a1b9101189e115b4eabbe941639f3c626614e986"}},
20 | 2},
21 | {<<"idna">>,{pkg,<<"idna">>,<<"6.0.0">>},3},
22 | {<<"jsx">>,{pkg,<<"jsx">>,<<"2.10.0">>},1},
23 | {<<"jwt">>,{pkg,<<"jwt">>,<<"0.1.9">>},2},
24 | {<<"lhttpc">>,{pkg,<<"lhttpc">>,<<"1.6.2">>},2},
25 | {<<"m_http">>,{pkg,<<"m_http">>,<<"0.2.2">>},1},
26 | {<<"meck">>,{pkg,<<"meck">>,<<"0.8.13">>},3},
27 | {<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},3},
28 | {<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.2.0">>},3},
29 | {<<"oauth2">>,
30 | {path,"../../libs/oauth2",{mtime,<<"2019-12-24T13:38:23Z">>}},
31 | 0},
32 | {<<"parse_trans">>,{pkg,<<"parse_trans">>,<<"3.3.0">>},4},
33 | {<<"permit">>,
34 | {git,"https://github.com/fogfish/permit",
35 | {ref,"7ace1307d2dd312aa140f3710efdbbba75c8f451"}},
36 | 1},
37 | {<<"pipe">>,{pkg,<<"pipes">>,<<"2.0.1">>},1},
38 | {<<"pipes">>,{pkg,<<"pipes">>,<<"2.0.1">>},2},
39 | {<<"pns">>,
40 | {git,"https://github.com/fogfish/pns",
41 | {ref,"f9b37630d0a8797063a2a443b42f14353ad858c8"}},
42 | 3},
43 | {<<"pts">>,
44 | {git,"https://github.com/fogfish/pts",
45 | {ref,"f98c594f1e596f81b5e54c9791bf69568327fba2"}},
46 | 2},
47 | {<<"serverless">>,
48 | {git,"https://github.com/fogfish/serverless",
49 | {ref,"c804e510e9c473941b1b457ba175f537f1939f39"}},
50 | 0},
51 | {<<"ssl_verify_fun">>,{pkg,<<"ssl_verify_fun">>,<<"1.1.4">>},3},
52 | {<<"uid">>,{pkg,<<"uid">>,<<"1.3.4">>},2},
53 | {<<"unicode_util_compat">>,{pkg,<<"unicode_util_compat">>,<<"0.4.1">>},4}]}.
54 | [
55 | {pkg_hash,[
56 | {<<"base16">>, <<"283644E2B21BD5915ACB7178BED7851FB07C6E5749B8FAD68A53C501092176D9">>},
57 | {<<"base64url">>, <<"36A90125F5948E3AFD7BE97662A1504B934DD5DAC78451CA6E9ABF85A10286BE">>},
58 | {<<"certifi">>, <<"867CE347F7C7D78563450A18A6A28A8090331E77FA02380B4A21962A65D36EE5">>},
59 | {<<"datum">>, <<"93B131203A60CFEA9FFFF6435A50DC24239F689DFEBB76E6AECF6CE689EFE8F4">>},
60 | {<<"eini">>, <<"DFFA48476FD89FB6E41CEEA0ADFA1BC6E7862CCD6584417442F8BB37E5D34715">>},
61 | {<<"erlcloud">>, <<"85A947B7F53C58A884590423437670679B61AF03536D7FB583BEEB2B5054D0F2">>},
62 | {<<"hackney">>, <<"9F8F471C844B8CE395F7B6D8398139E26DDCA9EBC171A8B91342EE15A19963F4">>},
63 | {<<"idna">>, <<"689C46CBCDF3524C44D5F3DDE8001F364CD7608A99556D8FBD8239A5798D4C10">>},
64 | {<<"jsx">>, <<"77760560D6AC2B8C51FD4C980E9E19B784016AA70BE354CE746472C33BEB0B1C">>},
65 | {<<"jwt">>, <<"F5687B0168AE3AA1130E0068C38A7D256E622789BF4724578ADF99A7CE5531DC">>},
66 | {<<"lhttpc">>, <<"044F16F0018C7AA7E945E9E9406C7F6035E0B8BC08BF77B00C78CE260E1071E3">>},
67 | {<<"m_http">>, <<"A869974B4651658786BCE18F2019E9B8DA5E5F8AEFD9A5CCE1291B941F05A2F9">>},
68 | {<<"meck">>, <<"FFEDB39F99B0B99703B8601C6F17C7F76313EE12DE6B646E671E3188401F7866">>},
69 | {<<"metrics">>, <<"25F094DEA2CDA98213CECC3AEFF09E940299D950904393B2A29D191C346A8486">>},
70 | {<<"mimerl">>, <<"67E2D3F571088D5CFD3E550C383094B47159F3EEE8FFA08E64106CDF5E981BE3">>},
71 | {<<"parse_trans">>, <<"09765507A3C7590A784615CFD421D101AEC25098D50B89D7AA1D66646BC571C1">>},
72 | {<<"pipe">>, <<"A2B56796C63690ED0E78BB77BB389AF250BD70AFA15A6869369DBDC11087D68F">>},
73 | {<<"pipes">>, <<"A2B56796C63690ED0E78BB77BB389AF250BD70AFA15A6869369DBDC11087D68F">>},
74 | {<<"ssl_verify_fun">>, <<"F0EAFFF810D2041E93F915EF59899C923F4568F4585904D010387ED74988E77B">>},
75 | {<<"uid">>, <<"42E30E22908E8E2FAA6227E9C261F1954CB540BE3C5A139E112369AE6CC451FC">>},
76 | {<<"unicode_util_compat">>, <<"D869E4C68901DD9531385BB0C8C40444EBF624E60B6962D95952775CAC5E90CD">>}]}
77 | ].
78 |
--------------------------------------------------------------------------------
/apps/client/src/client.app.src:
--------------------------------------------------------------------------------
1 | {application, client,
2 | [
3 | {description, "oauth2 client api"},
4 | {vsn, "0.0.0"},
5 | {modules, []},
6 | {registered, []},
7 | {applications,[
8 | kernel
9 | , stdlib
10 | , feta
11 | , oauth2
12 | ]},
13 | {env, []}
14 | ]
15 | }.
16 |
--------------------------------------------------------------------------------
/apps/client/src/client.erl:
--------------------------------------------------------------------------------
1 | -module(client).
2 |
3 | -compile({parse_transform, category}).
4 | -compile({parse_transform, generic}).
5 |
6 | -export([main/1]).
7 |
8 | %%
9 | %%
10 | main(Opts) ->
11 | {ok, _} = application:ensure_all_started(?MODULE),
12 | serverless:spawn(fun api/1, Opts).
13 |
14 | %%
15 | %%
16 | -spec api(map()) -> datum:either(map()).
17 |
18 | api(#{
19 | <<"httpMethod">> := <<"GET">>,
20 | <<"path">> := <<"/oauth2/client">>,
21 | <<"headers">> := #{
22 | <<"Authorization">> := <<"Bearer ", Token/binary>>
23 | }
24 | }) ->
25 | serverless_api:return(
26 | oauth2_client:lookup(Token)
27 | );
28 |
29 | api(#{
30 | <<"httpMethod">> := <<"POST">>,
31 | <<"path">> := <<"/oauth2/client">>,
32 | <<"headers">> := #{
33 | <<"Authorization">> := <<"Bearer ", Token/binary>>,
34 | <<"content-type">> := <<"application/json", _/binary>>
35 | },
36 | <<"body">> := Json
37 | }) ->
38 | serverless_api:return(
39 | oauth2_client:create(Token, jsx:decode(Json, [return_maps]))
40 | );
41 |
42 | api(#{
43 | <<"httpMethod">> := <<"DELETE">>,
44 | <<"path">> := <<"/oauth2/client/", _/binary>>,
45 | <<"headers">> := #{
46 | <<"Authorization">> := <<"Bearer ", Token/binary>>
47 | },
48 | <<"pathParameters">> := #{<<"id">> := Id}
49 | }) ->
50 | serverless_api:return(
51 | oauth2_client:remove(Token, Id)
52 | );
53 |
54 | api(Json) ->
55 | serverless:warning(Json),
56 | {error, not_supported}.
57 |
--------------------------------------------------------------------------------
/apps/client/test/client_SUITE.erl:
--------------------------------------------------------------------------------
1 | -module(client_SUITE).
2 |
3 | -export([all/0, init_per_suite/1, end_per_suite/1]).
4 | -compile(export_all).
5 | -compile({parse_transform, category}).
6 |
7 | all() ->
8 | [Test || {Test, NAry} <- ?MODULE:module_info(exports),
9 | Test =/= module_info,
10 | Test =/= init_per_suite,
11 | Test =/= end_per_suite,
12 | NAry =:= 1
13 | ].
14 |
15 | %%%----------------------------------------------------------------------------
16 | %%%
17 | %%% init
18 | %%%
19 | %%%----------------------------------------------------------------------------
20 | init_per_suite(Config) ->
21 | os:putenv("PERMIT_ISSUER", "https://example.com"),
22 | os:putenv("PERMIT_AUDIENCE", "suite"),
23 | os:putenv("PERMIT_CLAIMS", "read=true&write=true"),
24 | {ok, _} = application:ensure_all_started(oauth2),
25 | permit:create({iri, <<"org">>, <<"joe">>}, <<"secret">>, #{}),
26 | Config.
27 |
28 | end_per_suite(_Config) ->
29 | ok.
30 |
--------------------------------------------------------------------------------
/apps/client/test/tests.config:
--------------------------------------------------------------------------------
1 | {suites, ".", all}.
2 |
--------------------------------------------------------------------------------
/apps/github/src/github.app.src:
--------------------------------------------------------------------------------
1 | {application, github,
2 | [
3 | {description, "oauth2 authorization - github integration"},
4 | {vsn, "0.1.0"},
5 | {modules, []},
6 | {registered, []},
7 | {applications,[
8 | kernel
9 | ,stdlib
10 | ]},
11 | {env, []}
12 | ]
13 | }.
--------------------------------------------------------------------------------
/apps/github/src/github.erl:
--------------------------------------------------------------------------------
1 | %%
2 | %% @doc
3 | %% GitHub IAM
4 | -module(github).
5 | -compile({parse_transform, category}).
6 |
7 | -export([
8 | auth_url/0,
9 | account/1
10 | ]).
11 |
12 | %%
13 | %% build auth url for service provider
14 | -spec auth_url() -> uri:uri().
15 |
16 | auth_url() ->
17 | [option ||
18 | Root <- opts:val(oauth_url, undefined, github),
19 | Scope <- opts:val(scopes, undefined, github),
20 | Client <- opts:val(access_key, github),
21 | uri:new(Root),
22 | uri:q([{client_id, Client}, {scope, Scope}], _),
23 | uri:s(_)
24 | ].
25 |
26 | %%
27 | %%
28 | -spec account(_) -> datum:either(_).
29 |
30 | account(Url) ->
31 | [either ||
32 | Client <- authenticate_oauth_client(Url),
33 |
34 |
35 | %% exchange code with github
36 | cats:optionT(badarg, uri:q(<<"code">>, undefined, Url)),
37 | authenticate_github_user(_),
38 | create_account(Client, _)
39 | ].
40 |
41 | %%
42 | authenticate_oauth_client(Url) ->
43 | [either ||
44 | %% OAuth2 implements federated accounts for GitHub users
45 | %% The feature is only available for public application
46 | %% OAuth2 state flag is used to communicate clientId across the session
47 | cats:optionT(badarg, uri:q(<<"state">>, undefined, Url)),
48 | oauth2_client:lookup(_),
49 | oauth2_client:is_public(_)
50 | ].
51 |
52 | %%
53 | authenticate_github_user(Code) ->
54 | case (request_github_api(Code))(#{}) of
55 | [{Access, undefined} | State] ->
56 | lager:warning("[github] unauthorized attempt from ~s", [Access]),
57 | [knet:close(Sock) || Sock <- maps:values(maps:without([req, ret, so], State))],
58 | {error, unauthorized};
59 |
60 | [{Access, Source} | State] ->
61 | lager:notice("[github] access to ~s is derived from ~s", [Access, Source]),
62 | [knet:close(Sock) || Sock <- maps:values(maps:without([req, ret, so], State))],
63 | {ok, #{access => Access, master => scalar:s(opts:val(org, github))}}
64 | end.
65 |
66 | request_github_api(Code) ->
67 | [m_state ||
68 | Token <- github_access_token(Code),
69 | User <- github_user_profile(Token),
70 | Access <- allows_access_contributor(Token),
71 | cats:unit({User, Access})
72 | ].
73 |
74 | allows_access_contributor(Token) ->
75 | fun(State0) ->
76 | case (github_user_orgs(Token))(State0) of
77 | [undefined | State1] ->
78 | (github_user_contribution(Token))(State1);
79 |
80 | [_ | _] = State1 ->
81 | State1
82 | end
83 | end.
84 |
85 | %%
86 | %%
87 | github_access_token(Code) ->
88 | [m_http ||
89 | cats:new( url(token_url) ),
90 | cats:so(#{active => true}),
91 | cats:method('POST'),
92 | cats:header("Connection", "keep-alive"),
93 | cats:header("Content-Type", "application/x-www-form-urlencoded"),
94 | cats:header("Transfer-Encoding", "chunked"),
95 | cats:payload( access_token_request(Code) ),
96 | cats:request(),
97 | cats:require(content, lens:at(<<"access_token">>))
98 | ].
99 |
100 | %%
101 | %%
102 | github_user_profile(Token) ->
103 | [m_http ||
104 | cats:new( uri:segments([user], url(ghapi_url)) ),
105 | cats:so(#{active => true}),
106 | cats:method('GET'),
107 | cats:header("Connection", "keep-alive"),
108 | cats:header("Accept", "application/json"),
109 | cats:header("User-Agent", "knet"),
110 | cats:header("Authorization", <<"Bearer ", Token/binary>>),
111 | cats:request(),
112 | cats:require(content, lens:at(<<"login">>))
113 | ].
114 |
115 | %%
116 | %%
117 | github_user_orgs(Token) ->
118 | [m_http ||
119 | cats:new( uri:segments([user, orgs], url(ghapi_url)) ),
120 | cats:so(#{active => true}),
121 | cats:method('GET'),
122 | cats:header("Connection", "keep-alive"),
123 | cats:header("Accept", "application/json"),
124 | cats:header("User-Agent", "knet"),
125 | cats:header("Authorization", <<"Bearer ", Token/binary>>),
126 | cats:request(),
127 | cats:require(content,
128 | lens:c(
129 | lens:takewith(fun allowed_org/1, #{}),
130 | lens:at(<<"login">>)
131 | )
132 | )
133 | ].
134 |
135 | %%
136 | %%
137 | github_user_contribution(Token) ->
138 | [m_http ||
139 | cats:new( uri:q([{affiliation, collaborator}], uri:segments([user, repos], url(ghapi_url))) ),
140 | cats:so(#{active => true}),
141 | cats:method('GET'),
142 | cats:header("Connection", "keep-alive"),
143 | cats:header("Accept", "application/json"),
144 | cats:header("User-Agent", "knet"),
145 | cats:header("Authorization", <<"Bearer ", Token/binary>>),
146 | cats:request(),
147 | cats:require(content,
148 | lens:c(
149 | lens:takewith(fun allowed_contrib/1, #{}),
150 | lens:at(<<"name">>)
151 | )
152 | )
153 | ].
154 |
155 | allowed_org(#{<<"login">> := Org}) ->
156 | Org =:= scalar:s(opts:val(org, github)).
157 |
158 | allowed_contrib(#{<<"name">> := Name}) ->
159 | [identity ||
160 | scalar:s(opts:val(contrib, github)),
161 | binary:split(_, <<$,>>, [global]),
162 | lists:member(Name, _)
163 | ].
164 |
165 | access_token_request(Code) ->
166 | #{
167 | client_id => opts:val(access_key, github),
168 | client_secret => opts:val(secret_key, github),
169 | code => Code
170 | }.
171 |
172 | %%
173 | %%
174 | create_account(Client, #{access := Access, master := Master}) ->
175 | Secret = crypto:strong_rand_bytes(30),
176 | case permit:lookup(Access) of
177 | {error, not_found} ->
178 | %% token here
179 | permit:create(Access, Secret, #{
180 | <<"type">> => <<"oauth2:account">>,
181 | <<"uid">> => true,
182 | <<"master">> => Master
183 | });
184 |
185 | {ok, _} ->
186 | permit:update(Access, Secret, #{
187 | <<"type">> => <<"oauth2:account">>,
188 | <<"uid">> => true,
189 | <<"master">> => Master
190 | })
191 | end,
192 | case
193 | oauth2_token:exchange_code(Access, Secret)
194 | of
195 | {ok, Code} ->
196 | redirect_uri([{code, Code}], Client);
197 | {error, _} = Error ->
198 | redirect_uri([Error], Client)
199 | end.
200 |
201 |
202 | %%
203 | %%
204 | url(Key) ->
205 | uri:new(opts:val(Key, github)).
206 |
207 | %%
208 | redirect_uri(Status, Client) ->
209 | oauth2_client:redirect_uri(Client, Status).
210 |
211 |
--------------------------------------------------------------------------------
/apps/signin/src/signin.app.src:
--------------------------------------------------------------------------------
1 | {application, signin,
2 | [
3 | {description, "oauth2 sign-in client"},
4 | {vsn, "0.1.0"},
5 | {modules, []},
6 | {registered, []},
7 | {applications,[
8 | kernel
9 | ,stdlib
10 | ]},
11 | {env, []}
12 | ]
13 | }.
--------------------------------------------------------------------------------
/apps/signin/src/signin.erl:
--------------------------------------------------------------------------------
1 | -module(signin).
2 | -compile({parse_transform, category}).
3 |
4 | -export([
5 | main/1
6 | ]).
7 |
8 | %%
9 | %%
10 | opts() ->
11 | [
12 | {help, $h, "help", undefined, "Print usage"}
13 | , {profile, $f, "profile", string, "Configuration profile"}
14 | , {oauth2, $a, "oauth2", string, "Authorization server"}
15 | , {client, $c, "client", string, "Identity of client application"}
16 | , {access, $u, "username", string, "Username"}
17 | , {secret, $p, "password", string, "Password"}
18 | ].
19 |
20 | %%
21 | %%
22 | main(Args) ->
23 | {ok, {Opts, _Files}} = getopt:parse(opts(), Args),
24 | Profile = profile(Opts),
25 | case
26 | lists:member(help, Profile) orelse
27 | lens:get(lens:pair(oauth2, undefined), Profile) =:= undefined orelse
28 | lens:get(lens:pair(access, undefined), Profile) =:= undefined orelse
29 | lens:get(lens:pair(secret, undefined), Profile) =:= undefined
30 |
31 | of
32 | true ->
33 | getopt:usage(opts(), escript:script_name(), ""),
34 | halt(0);
35 | _ ->
36 | {ok, _} = application:ensure_all_started(ssl),
37 | {ok, _} = application:ensure_all_started(knet),
38 | main(
39 | lens:get(lens:pair(oauth2), Profile),
40 | lens:get(lens:pair(client, <<"oauth2-account">>), Profile),
41 | lens:get(lens:pair(access), Profile),
42 | lens:get(lens:pair(secret), Profile)
43 | )
44 | end.
45 |
46 | profile(Opts) ->
47 | case lens:get(lens:pair(profile, undefined), Opts) of
48 | undefined ->
49 | Opts;
50 | File ->
51 | {ok, Profile} = file:consult(File),
52 | Opts ++ Profile
53 | end.
54 |
55 | %%
56 | %%
57 | main(OAuth2, Client, Access, Secret) ->
58 | case
59 | m_http:once(
60 | [m_state ||
61 | signin(OAuth2, Client, Access, Secret),
62 | exchange(OAuth2, Client, _)
63 | ]
64 | )
65 | of
66 | {ok, Token} ->
67 | file:write(standard_io, Token),
68 | halt(0);
69 | {error, Reason} ->
70 | file:write(standard_error, io_lib:format("Unable to fetch token ~p~n", [Reason])),
71 | halt(128)
72 | end.
73 |
74 | signin(OAuth2, Client, Access, Secret) ->
75 | [m_http ||
76 | _ > "POST " ++ OAuth2 ++ "/oauth2/signin",
77 | _ > "Accept: */*",
78 | _ > "Connection: close",
79 | _ > "Content-Type: application/x-www-form-urlencoded",
80 | _ > #{
81 | access => Access,
82 | secret => Secret,
83 | response_type => <<"code">>,
84 | client_id => Client,
85 | state => <<"none">>
86 | },
87 |
88 | _ < 302,
89 | Url < "Location: _",
90 | _ < lens:c(lens:const(Url), lens_uri(fun uri:q/1), lens:pair(<<"code">>))
91 | ].
92 |
93 | lens_uri(Accessor) ->
94 | fun(Fun, Uri) ->
95 | lens:fmap(fun(_) -> Uri end, Fun( Accessor(uri:new(Uri)) ))
96 | end.
97 |
98 | exchange(OAuth2, Client, Code) ->
99 | [m_http ||
100 | _ > "POST " ++ OAuth2 ++ "/oauth2/token",
101 | _ > "Accept: */*",
102 | _ > "Connection: close",
103 | _ > "Content-Type: application/x-www-form-urlencoded",
104 | _ > #{
105 | grant_type => <<"authorization_code">>,
106 | client_id => Client,
107 | code => Code
108 | },
109 |
110 | _ < 200,
111 | _ < "Content-Type: application/json",
112 | _ < lens:at(<<"access_token">>)
113 | ].
114 |
--------------------------------------------------------------------------------
/apps/token/Makefile:
--------------------------------------------------------------------------------
1 | APP = token
2 | EVENT ?=
3 |
4 | include ../../serverless.mk
5 |
--------------------------------------------------------------------------------
/apps/token/rebar.config:
--------------------------------------------------------------------------------
1 | {erl_opts, []}.
2 |
3 | {deps, [
4 | {serverless, ".*",
5 | {git, "https://github.com/fogfish/serverless", {branch, master}}
6 | }
7 |
8 | , {feta, ".*",
9 | {git, "https://github.com/fogfish/feta", {branch, master}}
10 | }
11 |
12 | , {oauth2, {path, "../../libs/oauth2"}}
13 | ]}.
14 |
15 | {profiles, [
16 | {test, [{deps, [meck]}]}
17 | ]}.
18 |
19 | {plugins, [
20 | rebar3_path_deps
21 | ]}.
22 |
23 | %%
24 | %%
25 | {escript_main_app , token}.
26 | {escript_emu_args , "%%! -smp -sbt ts +A10 +K true\n"}.
27 | {escript_incl_apps , [serverless]}.
28 |
--------------------------------------------------------------------------------
/apps/token/rebar.lock:
--------------------------------------------------------------------------------
1 | {"1.1.0",
2 | [{<<"base16">>,{pkg,<<"base16">>,<<"1.0.0">>},2},
3 | {<<"base64url">>,{pkg,<<"base64url">>,<<"0.0.1">>},3},
4 | {<<"certifi">>,{pkg,<<"certifi">>,<<"2.5.1">>},3},
5 | {<<"datum">>,{pkg,<<"datum">>,<<"4.6.1">>},1},
6 | {<<"ddb">>,
7 | {git,"https://github.com/fogfish/ddb",
8 | {ref,"8486e553bd96e2646a1cf27e33c345c2f22ec1d7"}},
9 | 1},
10 | {<<"eini">>,{pkg,<<"eini">>,<<"1.2.6">>},2},
11 | {<<"erlcloud">>,{pkg,<<"erlcloud">>,<<"3.2.7">>},1},
12 | {<<"feta">>,
13 | {git,"https://github.com/fogfish/feta",
14 | {ref,"c5d251b3f995b96afd5e8ec7da61516842aa658c"}},
15 | 0},
16 | {<<"hackney">>,{pkg,<<"hackney">>,<<"1.15.1">>},2},
17 | {<<"hash">>,
18 | {git,"https://github.com/fogfish/hash",
19 | {ref,"a1b9101189e115b4eabbe941639f3c626614e986"}},
20 | 2},
21 | {<<"idna">>,{pkg,<<"idna">>,<<"6.0.0">>},3},
22 | {<<"jsx">>,{pkg,<<"jsx">>,<<"2.10.0">>},1},
23 | {<<"jwt">>,{pkg,<<"jwt">>,<<"0.1.9">>},2},
24 | {<<"lhttpc">>,{pkg,<<"lhttpc">>,<<"1.6.2">>},2},
25 | {<<"m_http">>,{pkg,<<"m_http">>,<<"0.2.2">>},1},
26 | {<<"meck">>,{pkg,<<"meck">>,<<"0.8.13">>},3},
27 | {<<"metrics">>,{pkg,<<"metrics">>,<<"1.0.1">>},3},
28 | {<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.2.0">>},3},
29 | {<<"oauth2">>,
30 | {path,"../../libs/oauth2",{mtime,<<"2019-12-24T13:38:23Z">>}},
31 | 0},
32 | {<<"parse_trans">>,{pkg,<<"parse_trans">>,<<"3.3.0">>},4},
33 | {<<"permit">>,
34 | {git,"https://github.com/fogfish/permit",
35 | {ref,"7ace1307d2dd312aa140f3710efdbbba75c8f451"}},
36 | 1},
37 | {<<"pipe">>,{pkg,<<"pipes">>,<<"2.0.1">>},1},
38 | {<<"pipes">>,{pkg,<<"pipes">>,<<"2.0.1">>},2},
39 | {<<"pns">>,
40 | {git,"https://github.com/fogfish/pns",
41 | {ref,"f9b37630d0a8797063a2a443b42f14353ad858c8"}},
42 | 3},
43 | {<<"pts">>,
44 | {git,"https://github.com/fogfish/pts",
45 | {ref,"f98c594f1e596f81b5e54c9791bf69568327fba2"}},
46 | 2},
47 | {<<"serverless">>,
48 | {git,"https://github.com/fogfish/serverless",
49 | {ref,"c804e510e9c473941b1b457ba175f537f1939f39"}},
50 | 0},
51 | {<<"ssl_verify_fun">>,{pkg,<<"ssl_verify_fun">>,<<"1.1.4">>},3},
52 | {<<"uid">>,{pkg,<<"uid">>,<<"1.3.4">>},2},
53 | {<<"unicode_util_compat">>,{pkg,<<"unicode_util_compat">>,<<"0.4.1">>},4}]}.
54 | [
55 | {pkg_hash,[
56 | {<<"base16">>, <<"283644E2B21BD5915ACB7178BED7851FB07C6E5749B8FAD68A53C501092176D9">>},
57 | {<<"base64url">>, <<"36A90125F5948E3AFD7BE97662A1504B934DD5DAC78451CA6E9ABF85A10286BE">>},
58 | {<<"certifi">>, <<"867CE347F7C7D78563450A18A6A28A8090331E77FA02380B4A21962A65D36EE5">>},
59 | {<<"datum">>, <<"93B131203A60CFEA9FFFF6435A50DC24239F689DFEBB76E6AECF6CE689EFE8F4">>},
60 | {<<"eini">>, <<"DFFA48476FD89FB6E41CEEA0ADFA1BC6E7862CCD6584417442F8BB37E5D34715">>},
61 | {<<"erlcloud">>, <<"85A947B7F53C58A884590423437670679B61AF03536D7FB583BEEB2B5054D0F2">>},
62 | {<<"hackney">>, <<"9F8F471C844B8CE395F7B6D8398139E26DDCA9EBC171A8B91342EE15A19963F4">>},
63 | {<<"idna">>, <<"689C46CBCDF3524C44D5F3DDE8001F364CD7608A99556D8FBD8239A5798D4C10">>},
64 | {<<"jsx">>, <<"77760560D6AC2B8C51FD4C980E9E19B784016AA70BE354CE746472C33BEB0B1C">>},
65 | {<<"jwt">>, <<"F5687B0168AE3AA1130E0068C38A7D256E622789BF4724578ADF99A7CE5531DC">>},
66 | {<<"lhttpc">>, <<"044F16F0018C7AA7E945E9E9406C7F6035E0B8BC08BF77B00C78CE260E1071E3">>},
67 | {<<"m_http">>, <<"A869974B4651658786BCE18F2019E9B8DA5E5F8AEFD9A5CCE1291B941F05A2F9">>},
68 | {<<"meck">>, <<"FFEDB39F99B0B99703B8601C6F17C7F76313EE12DE6B646E671E3188401F7866">>},
69 | {<<"metrics">>, <<"25F094DEA2CDA98213CECC3AEFF09E940299D950904393B2A29D191C346A8486">>},
70 | {<<"mimerl">>, <<"67E2D3F571088D5CFD3E550C383094B47159F3EEE8FFA08E64106CDF5E981BE3">>},
71 | {<<"parse_trans">>, <<"09765507A3C7590A784615CFD421D101AEC25098D50B89D7AA1D66646BC571C1">>},
72 | {<<"pipe">>, <<"A2B56796C63690ED0E78BB77BB389AF250BD70AFA15A6869369DBDC11087D68F">>},
73 | {<<"pipes">>, <<"A2B56796C63690ED0E78BB77BB389AF250BD70AFA15A6869369DBDC11087D68F">>},
74 | {<<"ssl_verify_fun">>, <<"F0EAFFF810D2041E93F915EF59899C923F4568F4585904D010387ED74988E77B">>},
75 | {<<"uid">>, <<"42E30E22908E8E2FAA6227E9C261F1954CB540BE3C5A139E112369AE6CC451FC">>},
76 | {<<"unicode_util_compat">>, <<"D869E4C68901DD9531385BB0C8C40444EBF624E60B6962D95952775CAC5E90CD">>}]}
77 | ].
78 |
--------------------------------------------------------------------------------
/apps/token/src/token.app.src:
--------------------------------------------------------------------------------
1 | {application, token,
2 | [
3 | {description, "oauth2 token api"},
4 | {vsn, "0.0.0"},
5 | {modules, []},
6 | {registered, []},
7 | {applications,[
8 | kernel
9 | , stdlib
10 | , feta
11 | , oauth2
12 | ]},
13 | {env, []}
14 | ]
15 | }.
16 |
--------------------------------------------------------------------------------
/apps/token/src/token.erl:
--------------------------------------------------------------------------------
1 | -module(token).
2 |
3 | -compile({parse_transform, category}).
4 | -compile({parse_transform, generic}).
5 |
6 | -export([main/1]).
7 |
8 | %%
9 | %%
10 | main(Opts) ->
11 | {ok, _} = application:ensure_all_started(?MODULE),
12 | serverless:spawn(fun api/1, Opts).
13 |
14 | %%
15 | %%
16 | -spec api(map()) -> datum:either(map()).
17 |
18 | api(#{
19 | <<"httpMethod">> := <<"POST">>,
20 | <<"path">> := <<"/oauth2/token">>,
21 | <<"headers">> := #{
22 | <<"content-type">> := <<"application/x-www-form-urlencoded", _/binary>>
23 | } = Headers,
24 | <<"body">> := Request
25 | }) ->
26 | serverless_api:return(oauth2:token(Headers, Request));
27 |
28 | %%
29 | %% https://tools.ietf.org/html/rfc7662
30 | %% Section 2.1. Introspection Request
31 | %% To prevent token scanning attacks, the endpoint MUST also require
32 | %% some form of authorization to access this endpoint.
33 | %% (The end-point is only available for confidential clients)
34 | %%
35 | api(#{
36 | <<"httpMethod">> := <<"POST">>,
37 | <<"path">> := <<"/oauth2/introspect">>,
38 | <<"headers">> := #{
39 | <<"content-type">> := <<"application/x-www-form-urlencoded", _/binary>>,
40 | <<"Authorization">> := Digest
41 | },
42 | <<"body">> := Request
43 | }) ->
44 | case
45 | [either ||
46 | oauth2_client:confidential(Digest),
47 | oauth2_codec:decode(Request),
48 | cats:optionT(badarg, lens:get(lens:at(<<"token">>, undefined), _)),
49 | permit:validate(_)
50 | ]
51 | of
52 | {ok, Claims} ->
53 | serverless_api:return({ok, Claims#{<<"active">> => true}});
54 | {error, _} ->
55 | serverless_api:return({ok, #{<<"active">> => false}})
56 | end;
57 |
58 | api(Json) ->
59 | serverless:warning(Json),
60 | {error, not_supported}.
61 |
--------------------------------------------------------------------------------
/apps/token/test/tests.config:
--------------------------------------------------------------------------------
1 | {suites, ".", all}.
2 |
--------------------------------------------------------------------------------
/apps/token/test/token_SUITE.erl:
--------------------------------------------------------------------------------
1 | -module(token_SUITE).
2 |
3 | -export([all/0, init_per_suite/1, end_per_suite/1]).
4 | -compile(export_all).
5 | -compile({parse_transform, category}).
6 |
7 | all() ->
8 | [Test || {Test, NAry} <- ?MODULE:module_info(exports),
9 | Test =/= module_info,
10 | Test =/= init_per_suite,
11 | Test =/= end_per_suite,
12 | NAry =:= 1
13 | ].
14 |
15 | %%%----------------------------------------------------------------------------
16 | %%%
17 | %%% init
18 | %%%
19 | %%%----------------------------------------------------------------------------
20 | init_per_suite(Config) ->
21 | os:putenv("PERMIT_ISSUER", "https://example.com"),
22 | os:putenv("PERMIT_AUDIENCE", "suite"),
23 | os:putenv("PERMIT_CLAIMS", "read=true&write=true"),
24 | {ok, _} = application:ensure_all_started(oauth2),
25 | IDP = base64url:encode(crypto:hash(md5, <<"joe@org">>)),
26 | permit:create({iri, IDP, <<"joe@org">>}, <<"secret">>, #{}),
27 | Config.
28 |
29 | end_per_suite(_Config) ->
30 | ok.
31 |
32 | %%
33 | %%
34 | code_exchange(_) ->
35 | IDP = base64url:encode(crypto:hash(md5, <<"joe@org">>)),
36 | {ok, Code} = [either ||
37 | permit:stateless({iri, IDP, <<"joe@org">>}, <<"secret">>, 10, #{}),
38 | oauth2_authorize:exchange_code(_, #{})
39 | ],
40 | #{
41 | statusCode := 200
42 | , body := Json
43 | } = serverless:mock(token,
44 | #{
45 | <<"httpMethod">> => <<"POST">>
46 | , <<"path">> => <<"/oauth2/token">>
47 | , <<"headers">> => #{
48 | <<"content-type">> => <<"application/x-www-form-urlencoded">>
49 | }
50 | , <<"body">> => <<"grant_type=authorization_code&client_id=account@oauth2&code=", Code/binary>>
51 | }
52 | ),
53 | #{
54 | <<"token_type">> := <<"bearer">>
55 | , <<"access_token">> := AccessToken
56 | , <<"expires_in">> := _
57 | , <<"aud">> := <<"suite">>
58 | , <<"idp">> := IDP
59 | , <<"iss">> := <<"https://example.com">>
60 | , <<"sub">> := <<"joe@org">>
61 | , <<"exp">> := _
62 | , <<"tji">> := _
63 | } = jsx:decode(Json, [return_maps]),
64 | {ok, #{
65 | <<"iss">> := <<"https://example.com">>
66 | , <<"aud">> := <<"suite">>
67 | , <<"idp">> := IDP
68 | , <<"sub">> := {iri, IDP, <<"joe@org">>}
69 | , <<"exp">> := _
70 | , <<"tji">> := _
71 | , <<"rev">> := true
72 | }} = permit:validate( AccessToken ).
73 |
--------------------------------------------------------------------------------
/buildspec.yml:
--------------------------------------------------------------------------------
1 | ##
2 | ## see https://docs.aws.amazon.com/codebuild/latest/userguide/build-spec-ref.html
3 | version: 0.2
4 |
5 | phases:
6 | install:
7 | commands:
8 | - echo "==> install"
9 | - make deps
10 |
11 | pre_build:
12 | commands:
13 | - echo "==> testing"
14 | - make test
15 |
16 | build:
17 | commands:
18 | - echo "==> build"
19 | - make dist
20 |
21 | post_build:
22 | commands:
23 | - echo "==> post build"
24 | - (cd cloud && cdk deploy oauth2-api-${BUILD_RELEASE} -c vsn=${BUILD_RELEASE} -c domain=${CONFIG_DOMAIN} -c email=${CONFIG_EMAIL} -c cert=${CONFIG_TLS_CERT})
25 | - make -C js/signin dist-up VSN=${BUILD_RELEASE}
26 | - make -C js/account dist-up VSN=${BUILD_RELEASE}
27 |
--------------------------------------------------------------------------------
/cleanspec.yml:
--------------------------------------------------------------------------------
1 | ##
2 | ## see https://docs.aws.amazon.com/codebuild/latest/userguide/build-spec-ref.html
3 | version: 0.2
4 |
5 | phases:
6 | install:
7 | commands:
8 | - echo "==> install"
9 | - npm --prefix cloud install
10 | - make -C apps/authorize deps
11 | - make -C apps/token deps
12 | - make -C apps/client deps
13 |
14 | pre_build:
15 | commands:
16 | - echo "==> pre build"
17 | build:
18 | commands:
19 | - echo "==> build"
20 | - make -C apps/authorize dist VSN=${BUILD_RELEASE}
21 | - make -C apps/token dist VSN=${BUILD_RELEASE}
22 | - make -C apps/client dist VSN=${BUILD_RELEASE}
23 |
24 | post_build:
25 | commands:
26 | - echo "==> post build"
27 | - make -C js/signin dist-rm VSN=${BUILD_RELEASE}
28 | - make -C js/account dist-rm VSN=${BUILD_RELEASE}
29 | - (cd cloud && cdk destroy -f oauth2-api-${BUILD_RELEASE} oauth2-db-${BUILD_RELEASE} -c vsn=${BUILD_RELEASE} -c domain=${CONFIG_DOMAIN} -c email=${CONFIG_EMAIL} -c cert=${CONFIG_TLS_CERT})
30 |
--------------------------------------------------------------------------------
/cloud/cdk.json:
--------------------------------------------------------------------------------
1 | {
2 | "app": "ts-node src/app",
3 | "requireApproval": "never"
4 | }
--------------------------------------------------------------------------------
/cloud/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "aws-oauth2",
3 | "version": "0.0.0",
4 | "dependencies": {
5 | "@types/node": "12.12.17",
6 | "aws-cdk-pure": "^1.2.9",
7 | "aws-cdk-pure-hoc": "^1.6.2",
8 | "@aws-cdk/core": "1.18.0",
9 | "@aws-cdk/aws-iam": "1.18.0",
10 | "@aws-cdk/aws-lambda": "1.18.0",
11 | "@aws-cdk/aws-apigateway": "1.18.0",
12 | "@aws-cdk/aws-logs": "1.18.0",
13 | "@aws-cdk/aws-dynamodb": "1.18.0"
14 | },
15 | "devDependencies": {
16 | "ts-node": "^8.5.4",
17 | "tslint": "^5.20.1",
18 | "prettier": "^1.19.1",
19 | "tslint-config-prettier": "^1.18.0",
20 | "typescript": "^3.7.3"
21 | },
22 | "scripts": {
23 | "clean": "rm -Rf cdk.out && rm -Rf node_modules",
24 | "lint": "tslint -p tsconfig.json"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/cloud/src/app.ts:
--------------------------------------------------------------------------------
1 | import * as lambda from '@aws-cdk/aws-lambda'
2 | import * as cdk from '@aws-cdk/core'
3 | import * as pure from 'aws-cdk-pure'
4 | import { gateway, staticweb } from 'aws-cdk-pure-hoc'
5 | import * as auth from './auth'
6 | import * as client from './client'
7 | import { DDB } from './storage'
8 | import * as token from './token'
9 |
10 | // ----------------------------------------------------------------------------
11 | //
12 | // Config
13 | //
14 | // ----------------------------------------------------------------------------
15 | const app = new cdk.App()
16 | const vsn: string = app.node.tryGetContext('vsn') || 'latest'
17 | const domain: string = app.node.tryGetContext('domain')
18 | const subdomain: string = `${vsn}.auth`
19 | const email: string = app.node.tryGetContext('email')
20 | const tlsCertificate: string = app.node.tryGetContext('cert')
21 | const host: string = `${subdomain}.${domain}`
22 | const stack = {
23 | env: {
24 | account: process.env.CDK_DEFAULT_ACCOUNT,
25 | region: process.env.CDK_DEFAULT_REGION
26 | }
27 | }
28 |
29 | // ----------------------------------------------------------------------------
30 | //
31 | // Storage Backend
32 | //
33 | // ----------------------------------------------------------------------------
34 | const storage = (vsn.startsWith('pr') || vsn === 'latest')
35 | ? `oauth2-db-${vsn}`
36 | : `oauth2-db-live`
37 |
38 | const dev = new cdk.Stack(app, storage, { ...stack })
39 | const ddb = pure.join(dev, DDB(vsn))
40 |
41 | // ----------------------------------------------------------------------------
42 | //
43 | // API Gateway
44 | //
45 | // ----------------------------------------------------------------------------
46 | const oauth2 = new cdk.Stack(app, `oauth2-api-${vsn}`, { ...stack })
47 |
48 | //
49 | const api = staticweb.Gateway({
50 | domain,
51 | sites: [
52 | {
53 | origin: 'signin',
54 | site: 'api/oauth2/authorize',
55 | },
56 | {
57 | origin: 'account',
58 | site: 'api/oauth2/account',
59 | }
60 | ],
61 | subdomain,
62 | tlsCertificate,
63 | })
64 |
65 | //
66 | const Layer = (): pure.IPure => {
67 | const LAYER='erlang-serverless:4'
68 | const iaac = pure.include(lambda.LayerVersion.fromLayerVersionArn)
69 | const AuthLayer= (): string => `arn:aws:lambda:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:layer:${LAYER}`
70 | return iaac(AuthLayer)
71 | }
72 |
73 | pure.join(oauth2,
74 | pure.use({ api, runtime: Layer() })
75 | .flatMap(x => ({
76 | auth: auth.Function(host, email, ddb, [x.runtime]),
77 | client: client.Function(host, ddb, [x.runtime]),
78 | token: token.Function(host, ddb, [x.runtime]),
79 | }))
80 | .effect(x => {
81 | const apiOAuth2 = x.api.root.getResource('oauth2')
82 | if (apiOAuth2) {
83 | apiOAuth2.addResource('signin').addMethod('POST', x.auth)
84 | apiOAuth2.addResource('signup').addMethod('POST', x.auth)
85 | apiOAuth2.addResource('password').addMethod('POST', x.auth)
86 |
87 | apiOAuth2.addResource('token').addMethod('POST', x.token)
88 | apiOAuth2.addResource('introspect').addMethod('POST', x.token)
89 |
90 | const apiClient = gateway.CORS(apiOAuth2.addResource('client'))
91 | apiClient.addMethod('GET', x.client)
92 | apiClient.addMethod('POST', x.client)
93 | gateway.CORS(apiClient.addResource('{id}')).addMethod('DELETE', x.client)
94 | }
95 | })
96 | )
97 |
98 | app.synth()
--------------------------------------------------------------------------------
/cloud/src/auth.ts:
--------------------------------------------------------------------------------
1 | import * as api from '@aws-cdk/aws-apigateway'
2 | import * as ddb from '@aws-cdk/aws-dynamodb'
3 | import * as iam from '@aws-cdk/aws-iam'
4 | import * as lambda from '@aws-cdk/aws-lambda'
5 | import * as logs from '@aws-cdk/aws-logs'
6 | import * as cdk from '@aws-cdk/core'
7 | import * as pure from 'aws-cdk-pure'
8 | import * as permit from './permit'
9 |
10 | //
11 | //
12 | export const Function = (host: string, email: string, db: ddb.Table, layers: lambda.ILayerVersion[]): pure.IPure =>
13 | pure.wrap(api.LambdaIntegration)(
14 | Role(db).flatMap(x => Lambda(host, email, db, x, layers))
15 | )
16 |
17 | //
18 | const Lambda = (host: string, email: string, db: ddb.Table, role: iam.IRole, layers: lambda.ILayerVersion[]): pure.IPure => {
19 | const iaac = pure.iaac(lambda.Function)
20 | const Auth = (): lambda.FunctionProps => ({
21 | code: new lambda.AssetCode('../apps/authorize/_build/default/bin'),
22 | environment: {
23 | 'OAUTH2_EMAIL': email,
24 | 'OAUTH2_EMAIL_SIGNATURE': 'Service',
25 | 'PERMIT_AUDIENCE': 'any',
26 | 'PERMIT_CLAIMS': '',
27 | 'PERMIT_ISSUER': `https://${host}`,
28 | 'PERMIT_KEYPAIR': 'permit_config_ddb',
29 | 'PERMIT_STORAGE': `ddb+https://dynamodb.${cdk.Aws.REGION}.amazonaws.com:443/${db.tableName}`,
30 | },
31 | handler: 'index.main',
32 | layers,
33 | logRetention: logs.RetentionDays.FIVE_DAYS,
34 | memorySize: 256,
35 | reservedConcurrentExecutions: 5,
36 | role,
37 | runtime: lambda.Runtime.PROVIDED,
38 | timeout: cdk.Duration.seconds(10),
39 | })
40 | return iaac(Auth)
41 | }
42 |
43 | //
44 | const Role = (db: ddb.Table): pure.IPure => {
45 | const role = pure.iaac(iam.Role)
46 | const AuthRole = (): iam.RoleProps => ({
47 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com')
48 | })
49 |
50 | return role(AuthRole).effect(x => {
51 | x.addToPolicy(permit.LambdaLogging())
52 | x.addToPolicy(permit.DynamoDbReadWrite(db.tableArn))
53 | })
54 | }
--------------------------------------------------------------------------------
/cloud/src/client.ts:
--------------------------------------------------------------------------------
1 | import * as api from '@aws-cdk/aws-apigateway'
2 | import * as ddb from '@aws-cdk/aws-dynamodb'
3 | import * as iam from '@aws-cdk/aws-iam'
4 | import * as lambda from '@aws-cdk/aws-lambda'
5 | import * as logs from '@aws-cdk/aws-logs'
6 | import * as cdk from '@aws-cdk/core'
7 | import * as pure from 'aws-cdk-pure'
8 | import * as permit from './permit'
9 |
10 | //
11 | //
12 | export const Function = (host: string, db: ddb.Table, layers: lambda.ILayerVersion[]): pure.IPure =>
13 | pure.wrap(api.LambdaIntegration)(
14 | Role(db).flatMap(x => Lambda(host, db, x, layers))
15 | )
16 |
17 | //
18 | const Lambda = (host: string, db: ddb.Table, role: iam.IRole, layers: lambda.ILayerVersion[]): pure.IPure => {
19 | const iaac = pure.iaac(lambda.Function)
20 | const Client = (): lambda.FunctionProps => ({
21 | code: new lambda.AssetCode('../apps/client/_build/default/bin'),
22 | environment: {
23 | 'PERMIT_AUDIENCE': 'any',
24 | 'PERMIT_CLAIMS': '',
25 | 'PERMIT_ISSUER': `https://${host}`,
26 | 'PERMIT_KEYPAIR': 'permit_config_ddb',
27 | 'PERMIT_STORAGE': `ddb+https://dynamodb.${cdk.Aws.REGION}.amazonaws.com:443/${db.tableName}`
28 | },
29 | handler: 'index.main',
30 | layers,
31 | logRetention: logs.RetentionDays.FIVE_DAYS,
32 | memorySize: 256,
33 | reservedConcurrentExecutions: 5,
34 | role,
35 | runtime: lambda.Runtime.PROVIDED,
36 | timeout: cdk.Duration.seconds(10),
37 | })
38 | return iaac(Client)
39 | }
40 |
41 | //
42 | const Role = (db: ddb.Table): pure.IPure => {
43 | const role = pure.iaac(iam.Role)
44 | const ClientRole = (): iam.RoleProps => ({
45 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com')
46 | })
47 |
48 | return role(ClientRole).effect(x => {
49 | x.addToPolicy(permit.LambdaLogging())
50 | x.addToPolicy(permit.DynamoDbReadWrite(db.tableArn))
51 | })
52 | }
--------------------------------------------------------------------------------
/cloud/src/permit.ts:
--------------------------------------------------------------------------------
1 | import * as iam from '@aws-cdk/aws-iam'
2 |
3 | export const LambdaLogging = (): iam.PolicyStatement =>
4 | new iam.PolicyStatement({
5 | actions: [
6 | 'logs:CreateLogGroup',
7 | 'logs:CreateLogStream',
8 | 'logs:PutLogEvents'
9 | ],
10 | resources: ['*'],
11 | })
12 |
13 | export const DynamoDbReadWrite = (arn: string): iam.PolicyStatement =>
14 | new iam.PolicyStatement({
15 | actions: [
16 | 'dynamodb:PutItem',
17 | 'dynamodb:UpdateItem',
18 | 'dynamodb:GetItem',
19 | 'dynamodb:Query',
20 | 'dynamodb:DeleteItem',
21 | ],
22 | resources: [arn],
23 | })
24 |
25 | export const DynamoDbReadOnly = (arn: string): iam.PolicyStatement =>
26 | new iam.PolicyStatement({
27 | actions: [
28 | 'dynamodb:GetItem',
29 | 'dynamodb:Query',
30 | ],
31 | resources: [arn],
32 | })
--------------------------------------------------------------------------------
/cloud/src/storage.ts:
--------------------------------------------------------------------------------
1 | import * as ddb from '@aws-cdk/aws-dynamodb'
2 | import * as cdk from '@aws-cdk/core'
3 | import * as pure from 'aws-cdk-pure'
4 |
5 | export const DDB = (vsn: string): pure.IPure => {
6 | const iaac = pure.iaac(ddb.Table)
7 | const AuthPubKey = (): ddb.TableProps => ({
8 | partitionKey: {type: ddb.AttributeType.STRING, name: 'prefix'},
9 | readCapacity: 1,
10 | removalPolicy: vsn.startsWith('pr') ? cdk.RemovalPolicy.DESTROY : cdk.RemovalPolicy.RETAIN,
11 | sortKey: {type: ddb.AttributeType.STRING, name: 'suffix'},
12 | tableName: `${cdk.Aws.STACK_NAME}-pubkey`,
13 | writeCapacity: 1,
14 | })
15 | return iaac(AuthPubKey)
16 | }
17 |
--------------------------------------------------------------------------------
/cloud/src/token.ts:
--------------------------------------------------------------------------------
1 | import * as api from '@aws-cdk/aws-apigateway'
2 | import * as ddb from '@aws-cdk/aws-dynamodb'
3 | import * as iam from '@aws-cdk/aws-iam'
4 | import * as lambda from '@aws-cdk/aws-lambda'
5 | import * as logs from '@aws-cdk/aws-logs'
6 | import * as cdk from '@aws-cdk/core'
7 | import * as pure from 'aws-cdk-pure'
8 | import * as permit from './permit'
9 |
10 | //
11 | //
12 | export const Function = (host: string, db: ddb.Table, layers: lambda.ILayerVersion[]): pure.IPure =>
13 | pure.wrap(api.LambdaIntegration)(
14 | Role(db).flatMap(x => Lambda(host, db, x, layers))
15 | )
16 |
17 | //
18 | const Lambda = (host: string, db: ddb.Table, role: iam.IRole, layers: lambda.ILayerVersion[]): pure.IPure => {
19 | const iaac = pure.iaac(lambda.Function)
20 | const Token = (): lambda.FunctionProps => ({
21 | code: new lambda.AssetCode('../apps/token/_build/default/bin'),
22 | environment: {
23 | 'PERMIT_AUDIENCE': 'any',
24 | 'PERMIT_CLAIMS': '',
25 | 'PERMIT_ISSUER': `https://${host}`,
26 | 'PERMIT_KEYPAIR': 'permit_config_ddb',
27 | 'PERMIT_STORAGE': `ddb+https://dynamodb.${cdk.Aws.REGION}.amazonaws.com:443/${db.tableName}`
28 | },
29 | handler: 'index.main',
30 | layers,
31 | logRetention: logs.RetentionDays.FIVE_DAYS,
32 | memorySize: 256,
33 | reservedConcurrentExecutions: 5,
34 | role,
35 | runtime: lambda.Runtime.PROVIDED,
36 | timeout: cdk.Duration.seconds(10),
37 | })
38 | return iaac(Token)
39 | }
40 |
41 | //
42 | const Role = (db: ddb.Table): pure.IPure => {
43 | const role = pure.iaac(iam.Role)
44 | const TokenRole = (): iam.RoleProps => ({
45 | assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com')
46 | })
47 |
48 | return role(TokenRole).effect(x => {
49 | x.addToPolicy(permit.LambdaLogging())
50 | x.addToPolicy(permit.DynamoDbReadOnly(db.tableArn))
51 | })
52 | }
--------------------------------------------------------------------------------
/cloud/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target":"ES2018",
4 | "module": "commonjs",
5 | "lib": ["es2016", "es2017.object", "es2017.string"],
6 | "strict": true,
7 | "noImplicitAny": true,
8 | "strictNullChecks": true,
9 | "noImplicitThis": true,
10 | "alwaysStrict": true,
11 | "noUnusedLocals": true,
12 | "noUnusedParameters": true,
13 | "noImplicitReturns": true,
14 | "noFallthroughCasesInSwitch": false,
15 | "inlineSourceMap": true,
16 | "inlineSources": true,
17 | "experimentalDecorators": true,
18 | "strictPropertyInitialization":false
19 | },
20 | "include": ["src"],
21 | "exclude": ["node_modules"]
22 | }
--------------------------------------------------------------------------------
/cloud/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["tslint:recommended", "tslint-config-prettier"],
3 | "rules": {
4 | "interface-name" : [true, "never-prefix"],
5 | "max-classes-per-file": [true, 2],
6 | "no-unused-expression": ["allow-new"]
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/doc/install.md:
--------------------------------------------------------------------------------
1 | # Install OAuth2 appliance
2 |
3 |
4 | ## AWS Elastic Container Service
5 |
6 | The application installation requires
7 | * AWS account
8 | * [AWS command-line tools](https://aws.amazon.com/cli/)
9 | * [AWS ECS command-line tools](http://docs.aws.amazon.com/AmazonECS/latest/developerguide/ECS_CLI.html)
10 | * Valid access right to execute `aws` utility.
11 |
12 | ### Provision backing service
13 |
14 | The appliance uses concept of backing service to attach all resource required for operation. It provides Infrastructure-as-a-Code templates to spawn required cloud resources and treats them as backing services.
15 |
16 | > A backing service is any service the app consumes over the network as part of its normal operation.
17 |
18 | Use the following command to spawn all required service. The script uses supplied aws cloud formation [template](../rel/aws/resources.yml). As the result, you'll get a new stack `${Env}-oauth2-resources` in your console.
19 |
20 | ```
21 | make config-aws ENV=live
22 | ```
23 |
24 | ### Configure OAuth2
25 |
26 | OAuth2 deployment requires a configuration to address you needs. Use the [compose file]((../rel/aws/compose.yml)) as a template.
27 |
28 | ```bash
29 | ## Enable DynamoDB as storage back-end,
30 | ## You might need to configure AWS Region only
31 | OAUTH2_STORAGE=ddb+https://dynamodb.eu-west-1.amazonaws.com:443/live-oauth2-pubkey?hashkey=access
32 |
33 | ## Server port and protocol, see Enable Secure Transport Layer if you need https
34 | OAUTH2_PORT=http://*:8080
35 | OAUTH2_TLS_CERTIFICATE=none
36 | OAUTH2_TLS_PRIVATE_KEY=none
37 |
38 | ## define claim issuer
39 | ## RFC 7519
40 | ## 4.1.1. "iss" (Issuer) Claim
41 | OAUTH2_ISSUER=http://localhost:8080
42 |
43 | ## define claim audience
44 | ## RFC 7519
45 | ## 4.1.3. "aud" (Audience) Claim
46 | OAUTH2_AUDIENCE=any
47 |
48 | ## comma separated list of default claim scopes
49 | OAUTH2_CLAIMS=uid=true
50 |
51 | ##
52 | ## configure token(s) time-to-live in seconds
53 | OAUTH2_TTL_ACCESS_TOKEN=3600
54 | OAUTH2_TTL_REFRESH_TOKEN=86400
55 | OAUTH2_TTL_EXCHANGE_CODE=60
56 | ```
57 |
58 | #### Enable GitHub integration
59 |
60 | OAuth2 supports integration with GitHub. This feature is a classical account federation. See the developer guidelines for [details](https://developer.github.com/apps/building-oauth-apps/). Once, you've enabled app at GitHub console, you can configure this server
61 |
62 | ```bash
63 | GITHUB_ACCESS_KEY=YourAccessKey
64 | GITHUB_SECRET_KEY=YourSecretKey
65 | GITHUB_ORG=YourGitHubOrg
66 | ```
67 |
68 | ### Spawn OAuth2 service
69 |
70 | The appliance supplies configuration to spawn service at AWS ECS. Just tell the cluster id at `ECS` variable and command will spawn the service
71 |
72 | ```
73 | make service-up ENV=live ECS=mycluster
74 | ```
75 |
76 | ### Enables Secure Transport Layer
77 |
78 | The usage of AWS ALB is the recommended approach to host OAuth2. However, this is becomes an overhead for extremely small projects. This authority server supports https protocol. Just change a protocol schema and provides certificates and private key. Note: only S3 as key source is supported. Easiest way to obtain trusted TLS certificate are [Let's Encrypt](https://letsencrypt.org) or [certbot-on-aws](https://github.com/fogfish/certbot-on-aws).
79 |
80 | ```
81 | OAUTH2_PORT=https://*:8443
82 | OAUTH2_TLS_CERTIFICATE=s3://letsencrypt/my.domain/cert.pem
83 | OAUTH2_TLS_PRIVATE_KEY=s3://letsencrypt/my.domain/privkey.pem
84 | ```
85 |
86 |
87 |
88 |
89 |
--------------------------------------------------------------------------------
/doc/restapi.yml:
--------------------------------------------------------------------------------
1 | openapi: "3.0.0"
2 | info:
3 | version: v2.0
4 | title: OAuth 2.0 Authorization Server Interface
5 | description:
6 | The interface implements an authorization layer and separating the role of
7 | the client from that of the resource owner.
8 |
9 | See The OAuth 2.0 Authorization Framework and other related specifications
10 |
11 | * https://tools.ietf.org/html/rfc6749
12 |
13 | * https://tools.ietf.org/html/rfc8252
14 |
15 | * https://tools.ietf.org/html/rfc7662
16 |
17 | * https://tools.ietf.org/html/rfc7519
18 |
19 |
20 | This document quotes listed specification to clarify details of interfaces.
21 |
22 | ##-----------------------------------------------------------------------------
23 | ##
24 | ## Interface end-points
25 | ##
26 | ##-----------------------------------------------------------------------------
27 |
28 | paths:
29 |
30 | ##
31 | ##
32 | /oauth2/authorize:
33 | get:
34 | description: |
35 | The authorization endpoint is used to interact with the resource owner
36 | and obtain an authorization grant. The OAuth app redirects a consumer's
37 | user agent to initiate either Authorization Code or Implicit Grants.
38 |
39 | See RFC 6749 for details
40 | * https://tools.ietf.org/html/rfc6749#section-4.1
41 | * https://tools.ietf.org/html/rfc6749#section-4.2
42 |
43 | The endpoint renders a user-expereince that verifies the identity of
44 | the resource owner. It uses a traditional username/password pair to
45 | authenticate the resources owners.
46 |
47 | After completing its interaction with the resource owner, the authorization
48 | server directs the resource owner's user-agent back to the client.
49 | The authorization server redirects the user-agent to the client's
50 | redirection endpoint previously established with the authorization
51 | server during the client registration process.
52 |
53 |
54 | parameters:
55 | - $ref: '#/components/parameters/client_id'
56 | - $ref: '#/components/parameters/response_type'
57 | - $ref: '#/components/parameters/state'
58 |
59 | responses:
60 | '200':
61 | description: |
62 | Renders OAuth 2.0 user expereince.
63 |
64 | content:
65 | text/html:
66 | schema:
67 | type: string
68 | format: binary
69 |
70 | default:
71 | description: |
72 | Unable to execute the request
73 | content:
74 | application/json:
75 | schema:
76 | $ref: '#/components/schemas/error'
77 |
78 | ##
79 | ##
80 | /oauth2/token:
81 | post:
82 | description: |
83 | The token endpoint is used by the client to obtain an access token by
84 | presenting its authorization grant or refresh token.
85 |
86 | requestBody:
87 | required: true
88 | content:
89 | application/x-www-form-urlencoded:
90 | schema:
91 | $ref: '#/components/schemas/request'
92 |
93 | responses:
94 | '200':
95 | description: |
96 | Authorization server exchanges the grant to valid token.
97 |
98 | content:
99 | application/json:
100 | schema:
101 | $ref: '#/components/schemas/token'
102 |
103 | default:
104 | description: |
105 | Unable to execute the request
106 | content:
107 | application/json:
108 | schema:
109 | $ref: '#/components/schemas/error'
110 |
111 | ##
112 | ##
113 | /oauth2/introspect:
114 | post:
115 | description: |
116 | The contents of access tokens are opaque to clients. The client does not
117 | need to know anything about the content or structure of the token itself,
118 | if there is any. However, there is still a large amount of metadata that
119 | may be attached to a token, such as its current validity, approved scopes,
120 | and information about the context in which the token was issued. These pieces
121 | of information are often vital to protected resources making authorization
122 | decisions based on the tokens being presented.
123 |
124 | This specification defines a protocol that allows authorized protected
125 | resources to query the authorization server to determine the set of metadata
126 | for a given token that was presented to them by an OAuth 2.0 client.
127 |
128 | See https://tools.ietf.org/html/rfc7662
129 |
130 | requestBody:
131 | required: true
132 | content:
133 | application/x-www-form-urlencoded:
134 | schema:
135 | type: object
136 | properties:
137 | token:
138 | type: string
139 | description: |
140 | access token issued by the service
141 |
142 | responses:
143 | '200':
144 | description: |
145 | Authorization server return claims assotiated with the token.
146 |
147 | content:
148 | application/json:
149 | schema:
150 | $ref: '#/components/schemas/claims'
151 |
152 | default:
153 | description: |
154 | Unable to execute the request
155 | content:
156 | application/json:
157 | schema:
158 | $ref: '#/components/schemas/error'
159 |
160 | ##
161 | ##
162 | /oauth2/jwks:
163 | get:
164 | description: |
165 | The endpoint returns a public key used to sign access tokens.
166 |
167 | responses:
168 | '200':
169 | description: |
170 | Authorization server return claims assotiated with the token.
171 |
172 | content:
173 | application/json:
174 | schema:
175 | $ref: '#/components/schemas/jwks'
176 |
177 | default:
178 | description: |
179 | Unable to execute the request
180 | content:
181 | application/json:
182 | schema:
183 | $ref: '#/components/schemas/error'
184 |
185 | ##
186 | ##
187 | /oauth2/signin:
188 | post:
189 | description: |
190 | Request the consumer sign-in with username/password credentials.
191 |
192 | Note: this api is not defined by OAuth 2.0 specification, it is defined
193 | by this authorization server to facilitate an implementation of
194 | branded identity management process.
195 |
196 | requestBody:
197 | required: true
198 | content:
199 | application/x-www-form-urlencoded:
200 | schema:
201 | $ref: '#/components/schemas/identity'
202 |
203 | responses:
204 | '302':
205 | description: |
206 | The authorization server validates credentials and redirects
207 | consumer's user-agent to redirect uri of the client who issues
208 | the request.
209 |
210 | default:
211 | description: |
212 | Unable to execute the request
213 | content:
214 | application/json:
215 | schema:
216 | $ref: '#/components/schemas/error'
217 |
218 |
219 | /oauth2/signup:
220 | post:
221 | description: |
222 | Request the consumer sign-up with username/password credentials.
223 |
224 | Note: this api is not defined by OAuth 2.0 specification, it is defined
225 | by this authorization server to facilitate an implementation of
226 | branded identity management process.
227 |
228 | requestBody:
229 | required: true
230 | content:
231 | application/x-www-form-urlencoded:
232 | schema:
233 | $ref: '#/components/schemas/identity'
234 |
235 | responses:
236 | '302':
237 | description: |
238 | The authorization server validates credentials, creates a basic
239 | profile and redirects consumer's user-agent to redirect uri of
240 | the client who issues the request.
241 |
242 | default:
243 | description: |
244 | Unable to execute the request
245 | content:
246 | application/json:
247 | schema:
248 | $ref: '#/components/schemas/error'
249 |
250 | components:
251 |
252 | ##-----------------------------------------------------------------------------
253 | ##
254 | ## Request parameters
255 | ##
256 | ##-----------------------------------------------------------------------------
257 | parameters:
258 |
259 | ##
260 | client_id:
261 | name: client_id
262 | in: query
263 | description: |
264 | The valid client identifier issued by the authorization server
265 | See https://tools.ietf.org/html/rfc6749#section-2.2
266 | required: true
267 | schema:
268 | type: string
269 |
270 | ##
271 | response_type:
272 | name: response_type
273 | in: query
274 | description: |
275 | The client informs the authorization server of the desired grant type
276 | See https://tools.ietf.org/html/rfc6749#section-3.1.1
277 | required: true
278 | schema:
279 | type: string
280 |
281 | ##
282 | state:
283 | name: state
284 | in: query
285 | description: |
286 | An opaque value used by the client to maintain state between the request and callback.
287 | required: true
288 | schema:
289 | type: string
290 |
291 |
292 | ##-----------------------------------------------------------------------------
293 | ##
294 | ## Data Types (Schemas) definition
295 | ##
296 | ##-----------------------------------------------------------------------------
297 | schemas:
298 |
299 | ##
300 | ##
301 | identity:
302 | type: object
303 | description: |
304 | The class exposes consumer and client identity required for sign-in, sign-up
305 | procedures.
306 | required:
307 | - access
308 | - secret
309 | - response_type
310 | - client_id
311 | - state
312 | properties:
313 | access:
314 | type: string
315 | description: |
316 | the access key is a public identity of the principal to whom access is granted.
317 | secret:
318 | type: string
319 | description: |
320 | the secret key is a private identity of the principal to whom access is granted.
321 | response_type:
322 | type: string
323 | description: |
324 | The client informs the authorization server of the desired grant type
325 | See https://tools.ietf.org/html/rfc6749#section-3.1.1
326 | client_id:
327 | type: string
328 | description: |
329 | The valid client identifier issued by the authorization server
330 | See https://tools.ietf.org/html/rfc6749#section-2.2
331 | state:
332 | type: string
333 | description: |
334 | An opaque value used by the client to maintain state between the request and callback.
335 |
336 | ##
337 | ##
338 | request:
339 | type: object
340 | description: |
341 | The data type represent the access token request
342 | * Authorization Code Grant https://tools.ietf.org/html/rfc6749#section-4.1.3
343 | * Resource Owner Password Credentials Grant https://tools.ietf.org/html/rfc6749#section-4.3.2
344 | * Client Credentials Grant https://tools.ietf.org/html/rfc6749#section-4.4.2
345 | * Refreshing access token https://tools.ietf.org/html/rfc6749#section-6
346 | required:
347 | - grant_type
348 | properties:
349 | grant_type:
350 | type: string
351 | description: |
352 | the type of grant
353 | code:
354 | type: string
355 | description: |
356 | The authorization code received from the authorization server.
357 | username:
358 | type: string
359 | description: |
360 | The resource owner username.
361 | password:
362 | type: string
363 | description: |
364 | The resource owner password.
365 | refresh_token:
366 | type: string
367 | description: |
368 | The refresh token issued to the client.
369 | scopes:
370 | type: string
371 | description: |
372 | The scope of the access request as described by https://tools.ietf.org/html/rfc6749#section-3.3
373 |
374 | ##
375 | ##
376 | token:
377 | type: object
378 | description: |
379 | The authorization server issues an access token and optional refresh token.
380 | https://tools.ietf.org/html/rfc6749#section-5.1
381 | required:
382 | - access_token
383 | - token_type
384 | - expires_in
385 | properties:
386 | access_token:
387 | type: string
388 | description: |
389 | The access token issued by the authorization server.
390 | token_type:
391 | type: string
392 | description: |
393 | The type of the token issued (usually bearer)
394 | expires_in:
395 | type: string
396 | description: |
397 | The lifetime in seconds of the access token.
398 | refresh_token:
399 | type: string
400 | description: |
401 | The refresh token, which can be used to obtain new access tokens.
402 | scopes:
403 | type: string
404 | description: |
405 | The scope of the access request as described by https://tools.ietf.org/html/rfc6749#section-3.3
406 |
407 | ##
408 | ##
409 | claims:
410 | type: object
411 | description: |
412 | It represent a set of security claims as a JSON object
413 | required:
414 | - iss
415 | - aud
416 | - sub
417 | - idp
418 | - app
419 | properties:
420 | iss:
421 | type: string
422 | description: |
423 | identifies the security token service (STS) that constructs and returns the token.
424 | aud:
425 | type: string
426 | description: |
427 | identifies intended recipient of the token. The application that receives the token
428 | must verify that the audience value is correct and reject any tokens intended for
429 | a different audience.
430 | sub:
431 | type: string
432 | description: |
433 | identifies the principal about which the token asserts information, such as
434 | the user of an application.
435 | idp:
436 | type: string
437 | description: |
438 | records the identity provider that authenticated the subject of the token.
439 | app:
440 | type: string
441 | description: |
442 | identifies the application that is using the token to access a resource.
443 | rev:
444 | type: string
445 | description: |
446 | identifies if token is revocable
447 |
448 | ##
449 | ##
450 | jwks:
451 | type: array
452 | items:
453 | $ref: "#/components/schemas/jwk"
454 |
455 | jwk:
456 | type: object
457 | required:
458 | - alg
459 | - kty
460 | - kid
461 | - e
462 | - n
463 | properties:
464 | alg:
465 | type: string
466 | description: |
467 | the algorithm for the key
468 | kty:
469 | type: string
470 | description: |
471 | the key type
472 | kid:
473 | type: string
474 | description: |
475 | the unique identifier for the key
476 | e:
477 | type: string
478 | description: |
479 | the exponent for a standard pem
480 | n:
481 | type: string
482 | description: |
483 | the modulus for a standard pem
484 |
485 | ##
486 | ##
487 | error:
488 | description: |
489 | The high-level class to expose interface errors (See RFC 7807).
490 | required:
491 | - type
492 | - status
493 | - title
494 | - instance
495 | - details
496 | properties:
497 | type:
498 | type: string
499 | description: |
500 | A URI reference that identifies the problem type.
501 | status:
502 | type: number
503 | format: integer
504 | description: |
505 | The HTTP status code generated by the interface
506 | title:
507 | type: string
508 | description: |
509 | A short, human-readable summary of the problem type.
510 | instance:
511 | type: string
512 | description: |
513 | A URI reference that identifies faulty resource and its context.
514 | details:
515 | type: string
516 | description: |
517 | A human-readable explanation specific to this occurrence of the problem.
518 |
--------------------------------------------------------------------------------
/doc/security.md:
--------------------------------------------------------------------------------
1 | # Security concerns
2 |
3 | The [OAuth 2.0 Framework](https://tools.ietf.org/html/rfc6749) provides security guidelines for implementers. This sections uses a FAQ style (quote - answer) to explains how these security concerns has been addressed by the OAuth 2.0 appliance.
4 |
5 | ## Client Authentication
6 |
7 | > The authorization server establishes client credentials with web application clients for the purpose of client authentication. The authorization server is encouraged to consider stronger client authentication means than a client password. Web application clients MUST ensure confidentiality of client passwords and other client credentials.
8 |
9 | > The authorization server MUST NOT issue client passwords or other client credentials to native application or user-agent-based application clients for the purpose of client authentication. The authorization server MAY issue a client password or other credentials for a specific installation of a native application client on a specific device.
10 |
11 | > When client authentication is not possible, the authorization server SHOULD employ other means to validate the client's identity -- for example, by requiring the registration of the client redirection URI or enlisting the resource owner to confirm identity. A valid redirection URI is not sufficient to verify the client's identity when asking for resource owner authorization but can be used to prevent delivering credentials to a counterfeit client after obtaining resource owner authorization.
12 |
13 | > The authorization server must consider the security implications of interacting with unauthenticated clients and take measures to limit the potential exposure of other credentials (e.g., refresh tokens) issued to such clients.
14 |
15 | ## Client Impersonation
16 |
17 | > A malicious client can impersonate another client and obtain access to protected resources if the impersonated client fails to, or is unable to, keep its client credentials confidential.
18 |
19 | > The authorization server MUST authenticate the client whenever possible. If the authorization server cannot authenticate the client due to the client's nature, the authorization server MUST require the registration of any redirection URI used for receiving authorization responses and SHOULD utilize other means to protect resource owners from such potentially malicious clients. For example, the authorization server can engage the resource owner to assist in identifying the client and its origin.
20 |
21 | > The authorization server SHOULD enforce explicit resource owner authentication and provide the resource owner with information about the client and the requested authorization scope and lifetime. It is up to the resource owner to review the information in the context of the current client and to authorize or deny the request.
22 |
23 | > The authorization server SHOULD NOT process repeated authorization requests automatically (without active resource owner interaction) without authenticating the client or relying on other measures to ensure that the repeated request comes from the original client and not an impersonator.
24 |
25 | ## Access Tokens
26 |
27 | > Access token credentials (as well as any confidential access token attributes) MUST be kept confidential in transit and storage, and only shared among the authorization server, the resource servers the access token is valid for, and the client to whom the access token is issued. Access token credentials MUST only be transmitted using TLS as described in Section 1.6 with server authentication as defined by [RFC2818].
28 |
29 | > When using the implicit grant type, the access token is transmitted in the URI fragment, which can expose it to unauthorized parties.
30 |
31 | > The authorization server MUST ensure that access tokens cannot be generated, modified, or guessed to produce valid access tokens by unauthorized parties.
32 |
33 | > The client SHOULD request access tokens with the minimal scope necessary. The authorization server SHOULD take the client identity into account when choosing how to honor the requested scope and MAY issue an access token with less rights than requested.
34 |
35 | > This specification does not provide any methods for the resource server to ensure that an access token presented to it by a given client was issued to that client by the authorization server.
36 |
37 | ## Refresh Tokens
38 |
39 | > Authorization servers MAY issue refresh tokens to web application clients and native application clients.
40 |
41 | > Refresh tokens MUST be kept confidential in transit and storage, and shared only among the authorization server and the client to whom the refresh tokens were issued. The authorization server MUST maintain the binding between a refresh token and the client to whom it was issued. Refresh tokens MUST only be transmitted using TLS as described in Section 1.6 with server authentication as defined by [RFC2818].
42 |
43 | > The authorization server MUST verify the binding between the refresh token and client identity whenever the client identity can be authenticated. When client authentication is not possible, the authorization server SHOULD deploy other means to detect refresh token abuse.
44 |
45 | > For example, the authorization server could employ refresh token rotation in which a new refresh token is issued with every access token refresh response. The previous refresh token is invalidated but retained by the authorization server. If a refresh token is compromised and subsequently used by both the attacker and the legitimate client, one of them will present an invalidated refresh token, which will inform the authorization server of the breach.
46 |
47 | > The authorization server MUST ensure that refresh tokens cannot be generated, modified, or guessed to produce valid refresh tokens by unauthorized parties.
48 |
49 |
50 | ## Authorization Codes
51 |
52 | > The transmission of authorization codes SHOULD be made over a secure channel, and the client SHOULD require the use of TLS with its redirection URI if the URI identifies a network resource. Since authorization codes are transmitted via user-agent redirections, they could potentially be disclosed through user-agent history and HTTP referrer headers.
53 |
54 | > Authorization codes operate as plaintext bearer credentials, used to verify that the resource owner who granted authorization at the authorization server is the same resource owner returning to the client to complete the process. Therefore, if the client relies on the authorization code for its own resource owner authentication, the client redirection endpoint MUST require the use of TLS.
55 |
56 | > Authorization codes MUST be short lived and single-use. If the authorization server observes multiple attempts to exchange an authorization code for an access token, the authorization server SHOULD attempt to revoke all access tokens already granted based on the compromised authorization code.
57 |
58 | > If the client can be authenticated, the authorization servers MUST authenticate the client and ensure that the authorization code was issued to the same client.
59 |
60 | ## Authorization Code Redirection URI Manipulation
61 |
62 | > When requesting authorization using the authorization code grant type, the client can specify a redirection URI via the "redirect_uri" parameter. If an attacker can manipulate the value of the redirection URI, it can cause the authorization server to redirect the resource owner user-agent to a URI under the control of the attacker with the authorization code.
63 |
64 | > An attacker can create an account at a legitimate client and initiate the authorization flow. When the attacker's user-agent is sent to the authorization server to grant access, the attacker grabs the authorization URI provided by the legitimate client and replaces the client's redirection URI with a URI under the control of the attacker. The attacker then tricks the victim into following the manipulated link to authorize access to the legitimate client.
65 |
66 | > Once at the authorization server, the victim is prompted with a normal, valid request on behalf of a legitimate and trusted client, and authorizes the request. The victim is then redirected to an endpoint under the control of the attacker with the authorization code. The attacker completes the authorization flow by sending the authorization code to the client using the original redirection URI provided by the client. The client exchanges the authorization code with an access token and links it to the attacker's client account, which can now gain access to the protected resources authorized by the victim (via the client).
67 |
68 | > In order to prevent such an attack, the authorization server MUST ensure that the redirection URI used to obtain the authorization code is identical to the redirection URI provided when exchanging the authorization code for an access token. The authorization server MUST require public clients and SHOULD require confidential clients to register their redirection URIs. If a redirection URI is provided in the request, the authorization server MUST validate it against the registered value.
69 |
70 | ## Resource Owner Password Credentials
71 |
72 | > The resource owner password credentials grant type is often used for legacy or migration reasons. It reduces the overall risk of storing usernames and passwords by the client but does not eliminate the need to expose highly privileged credentials to the client.
73 |
74 | > This grant type carries a higher risk than other grant types because it maintains the password anti-pattern this protocol seeks to avoid. The client could abuse the password, or the password could unintentionally be disclosed to an attacker (e.g., via log files or other records kept by the client).
75 |
76 | > Additionally, because the resource owner does not have control over the authorization process (the resource owner's involvement ends when it hands over its credentials to the client), the client can obtain access tokens with a broader scope than desired by the resource owner. The authorization server should consider the scope and lifetime of access tokens issued via this grant type.
77 |
78 | > The authorization server and client SHOULD minimize use of this grant type and utilize other grant types whenever possible.
79 |
80 | ## Request Confidentiality
81 |
82 | > Access tokens, refresh tokens, resource owner passwords, and client credentials MUST NOT be transmitted in the clear. Authorization codes SHOULD NOT be transmitted in the clear.
83 |
84 | > The "state" and "scope" parameters SHOULD NOT include sensitive client or resource owner information in plain text, as they can be transmitted over insecure channels or stored insecurely.
85 |
86 | ## Ensuring Endpoint Authenticity
87 |
88 | > In order to prevent man-in-the-middle attacks, the authorization server MUST require the use of TLS with server authentication as defined by [RFC2818] for any request sent to the authorization and token endpoints. The client MUST validate the authorization server's TLS certificate as defined by [RFC6125] and in accordance with its requirements for server identity authentication.
89 |
90 |
91 | ## Credentials-Guessing Attacks
92 |
93 | > The authorization server MUST prevent attackers from guessing access tokens, authorization codes, refresh tokens, resource owner passwords, and client credentials.
94 |
95 | > The probability of an attacker guessing generated tokens (and other credentials not intended for handling by end-users) MUST be less than or equal to 2^(-128) and SHOULD be less than or equal to 2^(-160).
96 |
97 | > The authorization server MUST utilize other means to protect credentials intended for end-user usage.
98 |
99 |
100 | ## Phishing Attacks
101 |
102 | > Wide deployment of this and similar protocols may cause end-users to become inured to the practice of being redirected to websites where they are asked to enter their passwords. If end-users are not careful to verify the authenticity of these websites before entering their credentials, it will be possible for attackers to exploit this practice to steal resource owners' passwords.
103 |
104 | > Service providers should attempt to educate end-users about the risks phishing attacks pose and should provide mechanisms that make it easy for end-users to confirm the authenticity of their sites. Client developers should consider the security implications of how they interact with the user-agent (e.g., external, embedded), and the ability of the end-user to verify the authenticity of the authorization server.
105 |
106 | > To reduce the risk of phishing attacks, the authorization servers MUST require the use of TLS on every endpoint used for end-user interaction.
107 |
108 | ## Cross-Site Request Forgery
109 |
110 | TBD
111 |
112 | ## Clickjacking
113 |
114 | > In a clickjacking attack, an attacker registers a legitimate client and then constructs a malicious site in which it loads the authorization server's authorization endpoint web page in a transparent iframe overlaid on top of a set of dummy buttons, which are carefully constructed to be placed directly under important buttons on the authorization page. When an end-user clicks a misleading visible button, the end-user is actually clicking an invisible button on the authorization page (such as an "Authorize" button). This allows an attacker to trick a resource owner into granting its client access without the end-user's knowledge.
115 |
116 | > To prevent this form of attack, native applications SHOULD use external browsers instead of embedding browsers within the application when requesting end-user authorization. For most newer browsers, avoidance of iframes can be enforced by the authorization server using the (non-standard) "x-frame-options" header. This header can have two values, "deny" and "sameorigin", which will block any framing, or framing by sites with a different origin, respectively. For older browsers, JavaScript frame-busting techniques can be used but may not be effective in all browsers.
117 |
118 | ## Code Injection and Input Validation
119 |
120 | > A code injection attack occurs when an input or otherwise external variable is used by an application unsanitized and causes modification to the application logic. This may allow an attacker to gain access to the application device or its data, cause denial of service, or introduce a wide range of malicious side-effects.
121 |
122 | > The authorization server and client MUST sanitize (and validate when possible) any value received -- in particular, the value of the "state" and "redirect_uri" parameters.
123 |
124 | ## Open Redirectors
125 |
126 | > The authorization server, authorization endpoint, and client redirection endpoint can be improperly configured and operate as open redirectors. An open redirector is an endpoint using a parameter to automatically redirect a user-agent to the location specified by the parameter value without any validation.
127 |
128 | > Open redirectors can be used in phishing attacks, or by an attacker to get end-users to visit malicious sites by using the URI authority component of a familiar and trusted destination. In addition, if the authorization server allows the client to register only part of the redirection URI, an attacker can use an open redirector operated by the client to construct a redirection URI that will pass the authorization server validation but will send the authorization code or access token to an endpoint under the control of the attacker.
129 |
130 | ## Misuse of Access Token to Impersonate Resource Owner in Implicit Flow
131 |
132 | > For public clients using implicit flows, this specification does not provide any method for the client to determine what client an access token was issued to.
133 |
134 | > A resource owner may willingly delegate access to a resource by granting an access token to an attacker's malicious client. This may be due to phishing or some other pretext. An attacker may also steal a token via some other mechanism. An attacker may then attempt to impersonate the resource owner by providing the access token to a legitimate public client.
135 |
136 | > In the implicit flow (response_type=token), the attacker can easily switch the token in the response from the authorization server, replacing the real access token with the one previously issued to the attacker.
137 |
138 | > Servers communicating with native applications that rely on being passed an access token in the back channel to identify the user of the client may be similarly compromised by an attacker creating a compromised application that can inject arbitrary stolen access tokens.
139 |
140 | > Any public client that makes the assumption that only the resource owner can present it with a valid access token for the resource is vulnerable to this type of attack.
141 |
142 | > This type of attack may expose information about the resource owner at the legitimate client to the attacker (malicious client). This will also allow the attacker to perform operations at the legitimate client with the same permissions as the resource owner who originally granted the access token or authorization code.
143 |
144 | > Authenticating resource owners to clients is out of scope for this specification. Any specification that uses the authorization process as a form of delegated end-user authentication to the client (e.g., third-party sign-in service) MUST NOT use the implicit flow without additional security mechanisms that would enable the client to determine if the access token was issued for its use (e.g., audience-restricting the access token).
145 |
146 |
147 | ## JWT is broken
148 |
149 | http://cryto.net/~joepie91/blog/2016/06/13/stop-using-jwt-for-sessions/
150 | https://paragonie.com/blog/2017/03/jwt-json-web-tokens-is-bad-standard-that-everyone-should-avoid
151 |
152 | ## Redirect end-point content
153 |
154 | 3.1.2.5. Endpoint Content
155 |
156 | The redirection request to the client's endpoint typically results in
157 | an HTML document response, processed by the user-agent. If the HTML
158 | response is served directly as the result of the redirection request,
159 | any script included in the HTML document will execute with full
160 | access to the redirection URI and the credentials it contains.
161 |
162 | The client SHOULD NOT include any third-party scripts (e.g., third-
163 | party analytics, social plug-ins, ad networks) in the redirection
164 | endpoint response. Instead, it SHOULD extract the credentials from
165 | the URI and redirect the user-agent again to another endpoint without
166 | exposing the credentials (in the URI or elsewhere). If third-party
167 | scripts are included, the client MUST ensure that its own scripts
168 | (used to extract and remove the credentials from the URI) will
169 | execute first.
170 |
171 |
--------------------------------------------------------------------------------
/js/account/.env:
--------------------------------------------------------------------------------
1 | ##
2 | ## app config
3 | ##
4 | REACT_APP_OAUTH2_AUTHORIZE=https://SITE/oauth2/authorize
5 | REACT_APP_OAUTH2_TOKEN=https://SITE/oauth2/token
6 | REACT_APP_OAUTH2_CLIENT=https://SITE/oauth2/client
7 | REACT_APP_OAUTH2_CLIENT_ID=account@oauth2
8 | REACT_APP_OAUTH2_FLOW_TYPE=code
9 |
10 |
--------------------------------------------------------------------------------
/js/account/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "commonjs": true,
4 | "es6": true,
5 | "node": true,
6 | "browser": true
7 | },
8 | "settings": {
9 | "import/resolver": {
10 | "node": {
11 | "paths": ["./src", "./src/style"],
12 | "extensions": [".js", ".jsx"]
13 | }
14 | }
15 | },
16 | "extends": [
17 | "eslint:recommended",
18 | "plugin:react/recommended",
19 | "airbnb"
20 | ],
21 | "parserOptions": {
22 | "ecmaVersion": 2018
23 | },
24 | "rules": {
25 | "max-classes-per-file": "off",
26 | "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }],
27 | "no-console": "error",
28 | "semi": "off",
29 | "react/prop-types": "off",
30 | "arrow-parens": ["error", "as-needed"],
31 | "react/jsx-props-no-spreading": ["error", {
32 | "exceptions": ["Component", "Recover", "Loading"]
33 | }],
34 | "camelcase": "off"
35 | }
36 | }
--------------------------------------------------------------------------------
/js/account/Makefile:
--------------------------------------------------------------------------------
1 | ## @doc
2 | ## static site deployment with AWS Gateway
3 | .PHONY: all deps test clean dist dist-up dist-rm
4 |
5 | ##
6 | ## Global config
7 | APP ?= account
8 | VSN ?= $(shell test -z "`git status --porcelain`" && git describe --tags --long | sed -e 's/-g[0-9a-f]*//' | sed -e 's/-0//' || echo "`git describe --abbrev=0 --tags`-a")
9 | SITE ?= ${VSN}.auth.${CONFIG_DOMAIN}
10 | ROOT ?= /oauth2/account
11 | BUILD = build
12 |
13 | ##
14 | ##
15 | all: test
16 |
17 | deps: | node_modules
18 |
19 | test: deps
20 | npm run lint
21 |
22 | .env.production: .env
23 | cp $^ $@
24 | sed -i 's/SITE/${SITE}/g' $@
25 |
26 | node_modules:
27 | @npm install
28 |
29 | clean:
30 | -@rm .env.production
31 | -@rm -Rf build
32 |
33 | ##
34 | ##
35 | dist: deps .env.production
36 | @PUBLIC_URL=${ROOT} npm run build
37 |
38 | dist-up:
39 | @aws s3 sync ${BUILD} s3://${SITE}/${APP}/ --delete
40 | @find ${BUILD} -name "*.html" | cut -d'/' -f2- |\
41 | xargs -L 1 -I {} aws s3 cp ${BUILD}/{} s3://${SITE}/${APP}/{} --cache-control "max-age=20"
42 |
43 | dist-rm:
44 | @aws s3 rm s3://${SITE}/${APP} --recursive
45 |
--------------------------------------------------------------------------------
/js/account/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "./src"
4 | },
5 | "exclude": ["node_modules", "build", "coverage", "dist", "lib"]
6 | }
--------------------------------------------------------------------------------
/js/account/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "oauth2-account",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@blueprintjs/core": "^3.19.1",
7 | "@blueprintjs/icons": "^3.12.0",
8 | "query-string": "^6.9.0",
9 | "react": "^16.12.0",
10 | "react-dom": "^16.12.0",
11 | "react-hook-oauth2": "^0.1.0",
12 | "react-scripts": "3.2.0",
13 | "reflexbox": "^4.0.6"
14 | },
15 | "scripts": {
16 | "start": "react-scripts start",
17 | "build": "react-scripts build",
18 | "test": "react-scripts test --env=jsdom",
19 | "eject": "react-scripts eject",
20 | "lint": "eslint src"
21 | },
22 | "browserslist": {
23 | "production": [
24 | ">0.2%",
25 | "not dead",
26 | "not op_mini all"
27 | ],
28 | "development": [
29 | "last 1 chrome version",
30 | "last 1 firefox version",
31 | "last 1 safari version"
32 | ]
33 | },
34 | "devDependencies": {
35 | "eslint": "^6.7.1",
36 | "eslint-config-airbnb": "^18.0.1",
37 | "eslint-plugin-react": "^7.17.0"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/js/account/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fogfish/oauth2/f758bf689833aa97947e7740e64cbb9211dfa5c2/js/account/public/favicon.ico
--------------------------------------------------------------------------------
/js/account/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Account Settings
9 |
10 |
11 |
12 |
13 | You need to enable JavaScript to run this app.
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/js/account/src/app.css:
--------------------------------------------------------------------------------
1 | @import "../node_modules/@blueprintjs/core/lib/css/blueprint.css";
2 |
3 | body
4 | {
5 | background: #F5F8FA;
6 | margin: 0;
7 | }
8 |
9 | table.bp3-html-table
10 | {
11 | width: 100%;
12 | border-collapse: collapse;
13 | }
14 |
15 | @media only screen and
16 | (max-width: 980px),
17 | (min-device-width: 980px) and (max-device-width: 1024px)
18 | {
19 | table.bp3-html-table,
20 | table.bp3-html-table thead,
21 | table.bp3-html-table tbody,
22 | table.bp3-html-table th,
23 | table.bp3-html-table td,
24 | table.bp3-html-table tr {
25 | display: block;
26 | }
27 |
28 | table.bp3-html-table thead tr {
29 | display: none;
30 | }
31 |
32 | table.bp3-html-table tbody tr:first-child td {
33 | border: none;
34 | box-shadow: none;
35 | }
36 |
37 | table.bp3-html-table td:before {
38 | display: block;
39 | font-weight: bold;
40 | content: attr(data-col);
41 | }
42 |
43 | }
44 |
45 | table.bp3-html-table th,
46 | table.bp3-html-table td
47 | {
48 | vertical-align: middle;
49 | }
50 |
51 | input
52 | {
53 | width: 100%;
54 | }
--------------------------------------------------------------------------------
/js/account/src/app.js:
--------------------------------------------------------------------------------
1 | import Account from './components/Account'
2 | import './app.css'
3 |
4 | export default Account
5 |
--------------------------------------------------------------------------------
/js/account/src/components/Account/Account.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Flex, Box } from 'reflexbox'
3 | import { useOAuth2, WhileIO } from 'react-hook-oauth2'
4 | import Header from './Header'
5 | import Registry from '../Registry'
6 | import Issue from '../Issue'
7 |
8 | const IO = WhileIO(undefined, Issue, Registry)
9 |
10 | const UI = () => {
11 | const status = useOAuth2()
12 | return ( )
13 | }
14 |
15 | const Account = () => (
16 | <>
17 |
18 |
19 |
20 |
21 |
22 |
23 | >
24 | )
25 |
26 | export default Account
27 |
--------------------------------------------------------------------------------
/js/account/src/components/Account/Header.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {
3 | Navbar,
4 | Alignment,
5 | Icon,
6 | Tooltip,
7 | Intent,
8 | } from '@blueprintjs/core'
9 | import { IconNames } from '@blueprintjs/icons'
10 | import { authorize, WhoIs } from 'react-hook-oauth2'
11 |
12 | const Account = () => WhoIs(({ sub }) => (<>{sub}>))
13 |
14 | const Header = () => (
15 |
16 |
17 | Account
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | )
28 |
29 | export default Header
30 |
--------------------------------------------------------------------------------
/js/account/src/components/Account/index.js:
--------------------------------------------------------------------------------
1 | import Account from './Account'
2 |
3 | export default Account
4 |
--------------------------------------------------------------------------------
/js/account/src/components/Issue/Issue.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { NonIdealState, Button, Intent } from '@blueprintjs/core'
3 | import { IconNames } from '@blueprintjs/icons'
4 | import { authorize } from 'react-hook-oauth2'
5 |
6 | const Unauthorized = () => (
7 | Please Sign-In!}
12 | />
13 | )
14 |
15 | const NotFound = () => (
16 | Unable to find requested file.
}
20 | />
21 | )
22 |
23 | const Unknown = ({ type, title }) => (
24 |
29 | )
30 |
31 | export default ({ status }) => {
32 | switch (status.reason.type) {
33 | case 'https://httpstatuses.com/401':
34 | return ( )
35 |
36 | case 'https://httpstatuses.com/404':
37 | return ( )
38 |
39 | default:
40 | return ( )
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/js/account/src/components/Issue/index.js:
--------------------------------------------------------------------------------
1 | import Issue from './Issue'
2 |
3 | export default Issue
4 |
--------------------------------------------------------------------------------
/js/account/src/components/NewApp/KeyPair/KeyPair.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {
3 | Button,
4 | Classes,
5 | Callout,
6 | Code,
7 | H5,
8 | Intent,
9 | } from '@blueprintjs/core'
10 |
11 | const Secret = ({ access, secret, hide }) => (
12 | <>
13 |
14 |
15 | This is the
16 | only
17 | time that the secret access keys can be viewed or downloaded.
18 | You cannot recover them later. However, you can create new access keys at any time.
19 |
20 |
21 |
22 | Access Key
23 | {access}
24 |
25 | Secret Key
26 | {secret}
27 |
28 |
29 |
30 |
31 |
32 | hide()}
35 | >
36 | Ok
37 |
38 |
39 |
40 | >
41 | )
42 |
43 | export default Secret
44 |
--------------------------------------------------------------------------------
/js/account/src/components/NewApp/KeyPair/index.js:
--------------------------------------------------------------------------------
1 | import KeyPair from './KeyPair'
2 |
3 | export default KeyPair
4 |
--------------------------------------------------------------------------------
/js/account/src/components/NewApp/NewApp.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { Dialog } from '@blueprintjs/core'
3 | import { useSecureCreate, SUCCESS } from 'react-hook-oauth2'
4 | import KeyPair from './KeyPair'
5 | import Registrar from './Registrar'
6 |
7 | const OAUTH2_CLIENT = process.env.REACT_APP_OAUTH2_CLIENT
8 | const emptyApp = { identity: undefined, redirect_uri: undefined, security: 'public' }
9 |
10 | const NewApp = ({ registrar, showRegistrar, append }) => {
11 | const [app, update] = useState(emptyApp)
12 | const { status, commit } = useSecureCreate(OAUTH2_CLIENT)
13 |
14 | return (
15 | showRegistrar(false)}
21 | >
22 | {(status instanceof SUCCESS)
23 | && (
24 | {
28 | append({ access: status.content.access, ...app })
29 | showRegistrar(false)
30 | commit(undefined)
31 | update(emptyApp)
32 | }}
33 | />
34 | )}
35 | {!(status instanceof SUCCESS)
36 | && (
37 | commit(app)}
42 | />
43 | )}
44 |
45 | )
46 | }
47 |
48 | export default NewApp
49 |
--------------------------------------------------------------------------------
/js/account/src/components/NewApp/Registrar/Endpoint.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Label, Classes } from '@blueprintjs/core'
3 |
4 | const Endpoint = ({ app, update }) => (
5 |
6 | Redirect Uri
7 |
8 | required
9 | update({ ...app, redirect_uri: e.target.value })}
15 | />
16 |
17 | )
18 |
19 | export default Endpoint
20 |
--------------------------------------------------------------------------------
/js/account/src/components/NewApp/Registrar/Identity.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Label, Classes } from '@blueprintjs/core'
3 |
4 | const Identity = ({ app, update }) => (
5 |
6 | Application
7 |
8 | required
9 | update({ ...app, app: e.target.value })}
15 | />
16 |
17 | )
18 |
19 | export default Identity
20 |
--------------------------------------------------------------------------------
/js/account/src/components/NewApp/Registrar/Registrar.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {
3 | Button,
4 | Classes,
5 | Intent,
6 | Callout,
7 | Code,
8 | } from '@blueprintjs/core'
9 | import { PENDING, FAILURE } from 'react-hook-oauth2'
10 | import Identity from './Identity'
11 | import Endpoint from './Endpoint'
12 | import Security from './Security'
13 |
14 | const Registrar = ({
15 | status,
16 | commit,
17 | app,
18 | update,
19 | }) => (
20 | <>
21 |
22 |
23 | Registers the application with the authorization server. Define client type
24 | based on their ability to maintain the confidentiality of their client credentials.
25 | The authorization server issues the registered client a client identifier and client secret.
26 |
27 |
28 | {(status instanceof FAILURE && status.reason.details !== 'invalid_uri')
29 | && (
30 |
31 | Unable to register an application. Try again later.
32 |
33 | )}
34 |
35 |
36 | {(status instanceof FAILURE && status.reason.details === 'invalid_uri')
37 | && (
38 |
39 | Invalid
40 | Redirect Uri
41 | . The schema, host and path are required.
42 | https://example.com/path
43 |
44 | )}
45 |
46 |
47 |
48 |
49 |
50 |
55 | Register
56 |
57 |
58 |
59 | >
60 | )
61 |
62 | export default Registrar
63 |
--------------------------------------------------------------------------------
/js/account/src/components/NewApp/Registrar/Security.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Label, RadioGroup, Radio } from '@blueprintjs/core'
3 |
4 | const Security = ({ app, update }) => (
5 |
6 | Application Type
7 |
8 | required
9 | update({ ...app, security: e.target.value })}
14 | >
15 |
16 |
17 |
18 |
19 | )
20 |
21 | export default Security
22 |
--------------------------------------------------------------------------------
/js/account/src/components/NewApp/Registrar/index.js:
--------------------------------------------------------------------------------
1 | import Registrar from './Registrar'
2 |
3 | export default Registrar
4 |
--------------------------------------------------------------------------------
/js/account/src/components/NewApp/index.js:
--------------------------------------------------------------------------------
1 | import NewApp from './NewApp'
2 |
3 | export default NewApp
4 |
--------------------------------------------------------------------------------
/js/account/src/components/Registry/Registry.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import {
3 | Card,
4 | H2,
5 | Button,
6 | Intent,
7 | Spinner,
8 | } from '@blueprintjs/core'
9 | import { WhileIO, SUCCESS, useSecureLookup } from 'react-hook-oauth2'
10 | import Issue from '../Issue'
11 | import RegistryNone from './RegistryNone'
12 | import RegistryList from './RegistryList'
13 | import NewApp from '../NewApp'
14 |
15 | const OAUTH2_CLIENT = process.env.REACT_APP_OAUTH2_CLIENT
16 |
17 | //
18 | const Head = ({ status, showRegistrar }) => (
19 |
20 | OAuth Apps
21 | {status instanceof SUCCESS
22 | && (
23 | showRegistrar(true)}
28 | >
29 | New OAuth App
30 |
31 | )}
32 |
33 | )
34 |
35 | const Registry = ({ content, revoke, showRegistrar }) => (content && content.length > 0
36 | ?
37 | :
38 | )
39 |
40 | const IO = WhileIO(Spinner, Issue, Registry)
41 |
42 | const RegistryWithData = () => {
43 | const { status } = useSecureLookup(OAUTH2_CLIENT)
44 | const [registry, updateRegistry] = useState(status)
45 | const [registrar, showRegistrar] = useState(false)
46 |
47 | useEffect(() => {
48 | updateRegistry(status)
49 | }, [status])
50 |
51 | const revoke = id => {
52 | updateRegistry(new SUCCESS(registry.content.filter(x => x.access !== id)))
53 | }
54 |
55 | const append = app => {
56 | updateRegistry(new SUCCESS(registry.content.concat([app])))
57 | }
58 |
59 | return (
60 |
61 |
62 |
63 |
64 |
65 | )
66 | }
67 |
68 | export default RegistryWithData
69 |
--------------------------------------------------------------------------------
/js/account/src/components/Registry/RegistryList.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {
3 | Code,
4 | AnchorButton,
5 | Intent,
6 | Icon,
7 | } from '@blueprintjs/core'
8 | import {
9 | useSecureRemove,
10 | PENDING,
11 | SUCCESS,
12 | FAILURE,
13 | } from 'react-hook-oauth2'
14 |
15 | const OAUTH2_CLIENT = process.env.REACT_APP_OAUTH2_CLIENT
16 |
17 | const Item = ({ client, revoke }) => {
18 | const { status, retry, sequence } = useSecureRemove(undefined)
19 |
20 | if (status instanceof SUCCESS) {
21 | revoke(client.access)
22 | }
23 |
24 | return (status.status instanceof SUCCESS
25 | ? null
26 | : (
27 |
28 | {client.app}
29 | {client.access}
30 | {client.security}
31 | {client.redirect_uri}
32 |
33 | (status instanceof FAILURE
38 | ? retry()
39 | : sequence(`${OAUTH2_CLIENT}/${client.access}`))}
40 | >
41 | revoke
42 |
43 |
44 |
45 | {status instanceof FAILURE
46 | ?
47 | : }
48 |
49 |
50 | )
51 | )
52 | }
53 |
54 |
55 | const RegistryList = ({ content, revoke }) => (
56 |
57 |
58 |
59 | Application
60 | Client ID
61 | Security
62 | Redirect Uri
63 |
64 |
65 |
66 |
67 |
68 | {content.map(x => )}
69 |
70 |
71 | )
72 |
73 | export default RegistryList
74 |
--------------------------------------------------------------------------------
/js/account/src/components/Registry/RegistryNone.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { H5, Button, Intent } from '@blueprintjs/core'
3 |
4 | const RegistryNone = ({ showRegistrar }) => (
5 |
6 |
No OAuth applications in your account...
7 |
OAuth applications are used to access REST API.
8 |
showRegistrar(true)}>
9 | Register New OAuth App
10 |
11 |
12 | )
13 |
14 | export default RegistryNone
15 |
--------------------------------------------------------------------------------
/js/account/src/components/Registry/index.js:
--------------------------------------------------------------------------------
1 | import Registry from './Registry'
2 |
3 | export default Registry
4 |
--------------------------------------------------------------------------------
/js/account/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import App from './app'
4 |
5 | ReactDOM.render( , document.getElementById('root'))
6 |
--------------------------------------------------------------------------------
/js/signin/.env:
--------------------------------------------------------------------------------
1 | ##
2 | ## defines prefix for sign-in/sign-up urls
3 | ## REACT_APP_PREFIX=https://auth.example.com
4 |
--------------------------------------------------------------------------------
/js/signin/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "commonjs": true,
4 | "es6": true,
5 | "node": true,
6 | "browser": true
7 | },
8 | "settings": {
9 | "import/resolver": {
10 | "node": {
11 | "paths": ["./src", "./src/style"],
12 | "extensions": [".js", ".jsx"]
13 | }
14 | }
15 | },
16 | "extends": [
17 | "eslint:recommended",
18 | "plugin:react/recommended",
19 | "airbnb"
20 | ],
21 | "parserOptions": {
22 | "ecmaVersion": 2018
23 | },
24 | "rules": {
25 | "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }],
26 | "no-console": "error",
27 | "semi": ["off", "always"],
28 | "react/prop-types": "off"
29 | }
30 | }
--------------------------------------------------------------------------------
/js/signin/Makefile:
--------------------------------------------------------------------------------
1 | ## @doc
2 | ## static site deployment with AWS Gateway
3 | .PHONY: all deps test clean dist dist-up dist-rm
4 |
5 | ##
6 | ## Global config
7 | APP ?= signin
8 | VSN ?= $(shell test -z "`git status --porcelain`" && git describe --tags --long | sed -e 's/-g[0-9a-f]*//' | sed -e 's/-0//' || echo "`git describe --abbrev=0 --tags`-a")
9 | SITE ?= ${VSN}.auth.${CONFIG_DOMAIN}
10 | ROOT ?= /oauth2/authorize
11 | BUILD = build
12 |
13 | ##
14 | ##
15 | all: test
16 |
17 | deps: | node_modules
18 |
19 | test: deps
20 | npm run lint
21 |
22 | .env.production:
23 | @echo "# do nothing" | tee -a $@
24 |
25 | node_modules:
26 | @npm install
27 |
28 | clean:
29 | -@rm .env.production
30 | -@rm -Rf build
31 |
32 | ##
33 | ##
34 | dist: deps .env.production
35 | @PUBLIC_URL=${ROOT} npm run build
36 |
37 | dist-up:
38 | @aws s3 sync ${BUILD} s3://${SITE}/${APP}/ --delete
39 | @find ${BUILD} -name "*.html" | cut -d'/' -f2- |\
40 | xargs -L 1 -I {} aws s3 cp ${BUILD}/{} s3://${SITE}/${APP}/{} --cache-control "max-age=20"
41 |
42 | dist-rm:
43 | @aws s3 rm s3://${SITE}/${APP} --recursive
44 |
--------------------------------------------------------------------------------
/js/signin/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "./src"
4 | },
5 | "exclude": ["node_modules", "build", "coverage", "dist", "lib"]
6 | }
--------------------------------------------------------------------------------
/js/signin/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "oauth2-default-ux",
3 | "version": "0.2.0",
4 | "private": true,
5 | "dependencies": {
6 | "@blueprintjs/core": "^3.19.1",
7 | "emotion-theming": "^10.0.19",
8 | "font-awesome": "^4.7.0",
9 | "query-string": "^6.9.0",
10 | "react": "^16.12.0",
11 | "react-dom": "^16.12.0",
12 | "react-router-dom": "^5.1.2",
13 | "reflexbox": "^4.0.6"
14 | },
15 | "devDependencies": {
16 | "eslint": "^6.7.1",
17 | "eslint-config-airbnb": "^18.0.1",
18 | "eslint-plugin-react": "^7.17.0",
19 | "react-scripts": "3.2.0"
20 | },
21 | "scripts": {
22 | "start": "react-scripts start",
23 | "build": "react-scripts build",
24 | "test": "react-scripts test --env=jsdom",
25 | "eject": "react-scripts eject",
26 | "lint": "eslint src"
27 | },
28 | "browserslist": [
29 | ">0.2%",
30 | "not dead",
31 | "not ie <= 11",
32 | "not op_mini all"
33 | ]
34 | }
35 |
--------------------------------------------------------------------------------
/js/signin/public/env.js:
--------------------------------------------------------------------------------
1 | // This file will not end up inside the main application JavaScript bundle.
2 | // Instead, it will simply be copied inside the build folder.
3 | // The generated "index.html" will require it just before this main bundle.
4 | // You can thus use it to define some environment variables that will
5 | // be made available synchronously in all your JS modules under "src".
6 | //
7 | // The content of this file is dynamically modified by REST API daemon
8 | // So that behavior of SignIn / SignUp process is customized without
9 | // needs to rebuild application (See restd_static:react_env_js/2)
10 | //
11 | // set feature value to false to disable it
12 | window.env = {
13 | //
14 | // Enables SignIn/SignUp using access/secret key pair
15 | KEYPAIR: true,
16 |
17 | //
18 | // Enable user initiated reset of secret key
19 | KEYPAIR_RESET: true,
20 |
21 | //
22 | // Enable SignIn using GitHub account
23 | GITHUB: "https://github.com/login/oauth/authorize?client_id=xxx&scope=xxx"
24 | }
25 |
--------------------------------------------------------------------------------
/js/signin/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fogfish/oauth2/f758bf689833aa97947e7740e64cbb9211dfa5c2/js/signin/public/favicon.ico
--------------------------------------------------------------------------------
/js/signin/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Sign-In
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/js/signin/src/app.css:
--------------------------------------------------------------------------------
1 | @import "../node_modules/@blueprintjs/core/lib/css/blueprint.css";
2 | @import "../node_modules/font-awesome/css/font-awesome.min.css";
3 |
4 | body
5 | {
6 | background: #F5F8FA;
7 | margin: 0;
8 | }
9 |
10 | .bp3-text-muted
11 | {
12 | font-size: 12px;
13 | color: #BFCCD6;
14 | }
15 |
16 | h1.bp3-heading
17 | {
18 | font-weight: 100;
19 | color: #293742;
20 | }
21 |
22 | input
23 | {
24 | width: 100%;
25 | }
26 |
27 | .oauth2-chip
28 | {
29 | display: inline-block;
30 | padding: 0 25px;
31 | height: 32px;
32 | line-height: 30px;
33 | border-radius: 16px;
34 | background-color: #f7f7f7;
35 | color: #4a4a4a;
36 | }
37 |
38 | .oauth2__secret,
39 | .oauth2__access
40 | {
41 | word-wrap: break-word;
42 | }
43 |
--------------------------------------------------------------------------------
/js/signin/src/app.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import {
3 | HashRouter as Router,
4 | Route,
5 | } from 'react-router-dom'
6 | import queryString from 'query-string'
7 | import { ThemeProvider } from 'emotion-theming'
8 | import SignIn from 'components/SignIn'
9 | import SignUp from 'components/SignUp'
10 | import SecretReset from 'components/SecretReset'
11 | import SecretRecover from 'components/SecretRecover'
12 | import './app.css'
13 |
14 | const params = queryString.parse(window.location.search)
15 | const oauth2 = {
16 | ...params,
17 | responseType: params.response_type || 'code',
18 | clientId: params.client_id || 'account@oauth2',
19 | state: params.state || '',
20 | redirectUri: params.redirect_uri || '',
21 | }
22 |
23 | const theme = {
24 | breakpoints: ['480px', '768px', '1024px'],
25 | space: [0, 4, 8, 16, 32, 64, 128, 256, 512],
26 | }
27 |
28 | const App = () => (
29 |
30 |
31 | <>
32 | } />
33 | } />
34 | } />
35 | } />
36 | >
37 |
38 |
39 | )
40 |
41 | export default App
42 |
--------------------------------------------------------------------------------
/js/signin/src/components/AccessKey.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Label, Classes } from '@blueprintjs/core'
3 |
4 | const AccessKey = () => (
5 |
6 | Email
7 | required
8 |
15 |
16 | )
17 |
18 | export default AccessKey
19 |
--------------------------------------------------------------------------------
/js/signin/src/components/Dialog.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Flex, Box } from 'reflexbox'
3 | import { Card, Elevation, Divider } from '@blueprintjs/core'
4 |
5 | const Head = ({ icon, title }) => (
6 |
7 |
8 |
9 | {title}
10 |
11 | )
12 |
13 | const FootLinks = (children) => (
14 | <>
15 |
16 |
17 | {children}
18 |
19 | >
20 | )
21 |
22 | const Foot = ({ Actions, Links }) => (
23 |
24 |
25 |
26 |
27 | {Links && {Links} }
28 |
29 | )
30 |
31 | const Dialog = ({
32 | icon,
33 | title,
34 | url,
35 | Actions,
36 | Links,
37 | children,
38 | }) => (
39 |
40 |
41 |
42 |
47 |
48 |
49 |
50 |
51 |
52 | )
53 |
54 | export default Dialog
55 |
--------------------------------------------------------------------------------
/js/signin/src/components/GitHub.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { AnchorButton, Intent } from '@blueprintjs/core'
3 |
4 | const GitHub = ({ clientId }) => (
5 |
10 |
11 | GitHub
12 |
13 | )
14 |
15 | export default GitHub
16 |
--------------------------------------------------------------------------------
/js/signin/src/components/KeyPair.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import AccessKey from './AccessKey'
3 | import SecretKey from './SecretKey'
4 |
5 | const KeyPair = ({ oauth2 }) => (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | )
15 |
16 | export default KeyPair
17 |
--------------------------------------------------------------------------------
/js/signin/src/components/SecretKey.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Label, Classes } from '@blueprintjs/core'
3 |
4 | const SecretKey = () => (
5 |
6 | Password
7 | required
8 |
15 |
16 | )
17 |
18 | export default SecretKey
19 |
--------------------------------------------------------------------------------
/js/signin/src/components/SecretRecover.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Button, Intent } from '@blueprintjs/core'
3 | import SecretKey from 'components/SecretKey'
4 | import Dialog from 'components/Dialog'
5 |
6 | const Actions = () => (
7 | <>
8 | <> >
9 | Reset Password
10 | >
11 | )
12 |
13 | const SecretRecover = ({ oauth2 }) => (
14 |
20 |
21 | Create a new password for
22 | {oauth2.access}
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | )
31 |
32 | export default SecretRecover
33 |
--------------------------------------------------------------------------------
/js/signin/src/components/SecretReset.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Button, Intent } from '@blueprintjs/core'
3 | import { Link } from 'react-router-dom'
4 | import Dialog from 'components/Dialog'
5 | import AccessKey from 'components/AccessKey'
6 |
7 | const Actions = () => (
8 | <>
9 |
10 | Sign In Instead
11 |
12 | Reset Password
13 | >
14 | )
15 |
16 | const SecretReset = ({ oauth2 }) => (
17 |
23 |
24 | Type you
25 | email
26 | address to reset your password.
27 | We will send recovery instructions over email.
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | )
36 |
37 | export default SecretReset
38 |
--------------------------------------------------------------------------------
/js/signin/src/components/SignIn.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Button, Intent } from '@blueprintjs/core'
3 | import { Link } from 'react-router-dom'
4 | import Dialog from 'components/Dialog'
5 | import KeyPair from 'components/KeyPair'
6 | // import { GitHub } from 'components/GitHub'
7 |
8 | const prefix = process.env.REACT_APP_PREFIX || ''
9 |
10 | const Actions = () => (
11 | <>
12 |
13 | Create Account
14 |
15 | Sign In
16 | >
17 | )
18 |
19 | /*
20 | TODO: Enable GitHub login feature
21 |
22 | const Links = props => (
23 | <>
24 | {window.env.GITHUB &&
25 |
26 | }
27 | >
28 | )
29 | */
30 |
31 | const SignIn = ({ oauth2 }) => (
32 |
38 | {window.env.KEYPAIR
39 | && }
40 | {(window.env.KEYPAIR && window.env.KEYPAIR_RESET)
41 | && Forgot Password?}
42 |
43 | )
44 |
45 | export default SignIn
46 |
--------------------------------------------------------------------------------
/js/signin/src/components/SignUp.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Button, Intent } from '@blueprintjs/core'
3 | import { Link } from 'react-router-dom'
4 | import Dialog from 'components/Dialog'
5 | import KeyPair from 'components/KeyPair'
6 |
7 | const prefix = process.env.REACT_APP_PREFIX || ''
8 |
9 | const Actions = () => (
10 | <>
11 |
12 | Sign In Instead
13 |
14 | Sign Up
15 | >
16 | )
17 |
18 | const SignUp = ({ oauth2 }) => (
19 |
25 |
26 |
27 | )
28 |
29 | export default SignUp
30 |
--------------------------------------------------------------------------------
/js/signin/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import App from './app'
4 |
5 | ReactDOM.render( , document.getElementById('root'))
6 |
--------------------------------------------------------------------------------
/libs/oauth2/Makefile:
--------------------------------------------------------------------------------
1 | APP = oauth2
2 | ORG =
3 | URI =
4 |
5 | include beam.mk
--------------------------------------------------------------------------------
/libs/oauth2/beam.mk:
--------------------------------------------------------------------------------
1 | ##
2 | ## Copyright (C) 2012 Dmitry Kolesnikov
3 | ##
4 | ## This Makefile may be modified and distributed under the terms
5 | ## of the MIT license. See the LICENSE file for details.
6 | ## https://github.com/fogfish/makefile
7 | ##
8 | ## @doc
9 | ## This makefile is the wrapper of rebar to build and ship erlang software
10 | ##
11 | ## @version 1.0.13
12 | .PHONY: all compile test unit clean distclean run console mock-up mock-rm benchmark release dist
13 |
14 | APP := $(strip $(APP))
15 | ORG := $(strip $(ORG))
16 | URI := $(strip $(URI))
17 |
18 | ##
19 | ## config
20 | PREFIX ?= /usr/local
21 | APP ?= $(notdir $(CURDIR))
22 | ARCH = $(shell uname -m)
23 | PLAT ?= $(shell uname -s)
24 | VSN ?= $(shell test -z "`git status --porcelain`" && git describe --tags --long | sed -e 's/-g[0-9a-f]*//' | sed -e 's/-0//' || echo "`git describe --abbrev=0 --tags`-dev")
25 | LATEST ?= latest
26 | REL = ${APP}-${VSN}
27 | PKG = ${REL}+${ARCH}.${PLAT}
28 | TEST ?= tests
29 | COOKIE ?= nocookie
30 | DOCKER ?= fogfish/erlang-alpine
31 | IID = ${URI}${ORG}/${APP}
32 |
33 | ## required tools
34 | ## - rebar version (no spaces at end)
35 | ## - path to basho benchmark
36 | REBAR ?= 3.12.0
37 | BB = ../basho_bench
38 |
39 |
40 | ## erlang runtime configration flags
41 | ROOT = $(shell pwd)
42 | ADDR = localhost.localdomain
43 | EFLAGS = \
44 | -name ${APP}@${ADDR} \
45 | -setcookie ${COOKIE} \
46 | -pa ${ROOT}/_build/default/lib/*/ebin \
47 | -pa ${ROOT}/_build/default/lib/*/priv \
48 | -pa ${ROOT}/rel \
49 | -kernel inet_dist_listen_min 32100 \
50 | -kernel inet_dist_listen_max 32199 \
51 | +P 1000000 \
52 | +K true +A 160 -sbt ts
53 |
54 |
55 | ## erlang common test bootstrap
56 | BOOT_CT = \
57 | -module(test). \
58 | -export([run/1]). \
59 | run(Spec) -> \
60 | {ok, Test} = file:consult(Spec), \
61 | case lists:keymember(node, 1, Test) of \
62 | false -> \
63 | erlang:halt(element(2, ct:run_test([{spec, Spec}]))); \
64 | true -> \
65 | ct_master:run(Spec), \
66 | erlang:halt(0) \
67 | end.
68 |
69 |
70 | ##
71 | BUILDER = FROM ${DOCKER}\nARG VERSION=\nRUN mkdir ${APP}\nCOPY . ${APP}/\nRUN cd ${APP} && make VSN=\x24{VERSION} && make release VSN=\x24{VERSION}\n
72 | SPAWNER = FROM ${DOCKER}\nENV VERSION=${VSN}\nRUN mkdir ${APP}\nCOPY . ${APP}/\nRUN cd ${APP} && make VSN=\x24{VERSION} && make release VSN=\x24{VERSION}\nCMD sh -c 'cd ${APP} && make console VSN=\x24{VERSION} RELX_REPLACE_OS_VARS=true ERL_NODE=${APP}'\n
73 |
74 | ## self extracting bundle archive
75 | BUNDLE_INIT = PREFIX=${PREFIX}\nREL=${PREFIX}/${REL}\nAPP=${APP}\nVSN=${VSN}\nLINE=`grep -a -n "BUNDLE:$$" $$0`\nmkdir -p $${REL}\ntail -n +$$(( $${LINE%%%%:*} + 1)) $$0 | gzip -vdc - | tar -C $${REL} -xvf - > /dev/null\n
76 | BUNDLE_FREE = exit\nBUNDLE:\n
77 |
78 |
79 | #####################################################################
80 | ##
81 | ## build
82 | ##
83 | #####################################################################
84 | all: rebar3 compile test
85 |
86 | compile: rebar3
87 | @./rebar3 compile
88 |
89 |
90 | ##
91 | ## execute common test and terminate node
92 | test:
93 | @./rebar3 ct --config=test/${TEST}.config --cover --verbose
94 | @./rebar3 cover
95 |
96 | # test: _build/test.beam
97 | # @mkdir -p /tmp/test/${APP}
98 | # @erl ${EFLAGS} -noshell -pa _build/ -pa test/ -run test run test/${TEST}.config
99 | # @F=`ls /tmp/test/${APP}/ct_run*/all.coverdata | tail -n 1` ;\
100 | # cp $$F /tmp/test/${APP}/ct.coverdata
101 | #
102 | # _build/test.beam: _build/test.erl
103 | # @erlc -o _build $<
104 | #
105 | # _build/test.erl:
106 | # @mkdir -p _build && echo "${BOOT_CT}" > $@
107 | #
108 |
109 | testclean:
110 | @rm -f _build/test.beam
111 | @rm -f _build/test.erl
112 | @rm -f test/*.beam
113 | @rm -rf test.*-temp-data
114 | @rm -rf tests
115 |
116 | ##
117 | ## execute unit test
118 | unit: all
119 | @./rebar3 skip_deps=true eunit
120 |
121 | ##
122 | ## clean
123 | clean: testclean dockerclean
124 | -@./rebar3 clean
125 | @rm -Rf _build/builder
126 | @rm -Rf _build/default/rel
127 | @rm -rf log
128 | @rm -f relx.config
129 | @rm -f *.tar.gz
130 | @rm -f *.bundle
131 |
132 | distclean: clean
133 | -@make mock-rm
134 | -@make dist-rm
135 | -@rm -Rf _build
136 | -@rm rebar3
137 |
138 | #####################################################################
139 | ##
140 | ## debug
141 | ##
142 | #####################################################################
143 | run:
144 | @erl ${EFLAGS}
145 |
146 | console: ${PKG}.tar.gz
147 | @_build/default/rel/${APP}/bin/${APP} foreground
148 |
149 | mock-up: test/mock/docker-compose.yml
150 | @docker-compose -f $< up
151 |
152 | mock-rm: test/mock/docker-compose.yml
153 | -@docker-compose -f $< down --rmi all -v --remove-orphans
154 |
155 | dist-up: docker-compose.yml _build/spawner
156 | @docker-compose build
157 | @docker-compose -f $< up
158 |
159 | dist-rm: docker-compose.yml
160 | -@rm -f _build/spawner
161 | -@docker-compose -f $< down --rmi all -v --remove-orphans
162 |
163 | benchmark:
164 | @echo "==> benchmark: ${TEST}" ;\
165 | $(BB)/basho_bench -N bb@127.0.0.1 -C nocookie priv/${TEST}.benchmark ;\
166 | $(BB)/priv/summary.r -i tests/current ;\
167 | open tests/current/summary.png
168 |
169 | #####################################################################
170 | ##
171 | ## release
172 | ##
173 | #####################################################################
174 | release: ${PKG}.tar.gz
175 |
176 | ## assemble VM release
177 | ifeq (${PLAT},$(shell uname -s))
178 | ${PKG}.tar.gz: relx.config
179 | @./rebar3 tar -n ${APP} -v ${VSN} ;\
180 | mv _build/default/rel/${APP}/${APP}-${VSN}.tar.gz $@ ;\
181 | echo "==> tarball: $@"
182 |
183 | relx.config: rel/relx.config.src
184 | @cat $< | sed "s/release/release, {'${APP}', \"${VSN}\"}/" > $@
185 | else
186 | ${PKG}.tar.gz: _build/builder
187 | @docker build --file=$< --force-rm=true --build-arg="VERSION=${VSN}" --tag=build/${APP}:latest . ;\
188 | I=`docker create build/${APP}:latest` ;\
189 | docker cp $$I:/${APP}/$@ $@ ;\
190 | docker rm -f $$I ;\
191 | docker rmi build/${APP}:latest ;\
192 | test -f $@ && echo "==> tarball: $@"
193 |
194 | _build/builder:
195 | @mkdir -p _build && echo "${BUILDER}" > $@
196 | endif
197 |
198 | ## build docker image
199 | docker: Dockerfile
200 | git status --porcelain
201 | test -z "`git status --porcelain`" || exit -1
202 | docker build \
203 | --build-arg APP=${APP} \
204 | --build-arg VSN=${VSN} \
205 | -t ${IID}:${VSN} -f $< .
206 | docker tag ${IID}:${VSN} ${IID}:${LATEST}
207 |
208 | dockerclean:
209 | -@docker rmi -f ${IID}:${LATEST}
210 | -@docker rmi -f ${IID}:${VSN}
211 |
212 | _build/spawner:
213 | @mkdir -p _build && echo "${SPAWNER}" > $@
214 |
215 |
216 | dist: ${PKG}.tar.gz ${PKG}.bundle
217 |
218 |
219 | ${PKG}.bundle: rel/bootstrap.sh
220 | @printf '${BUNDLE_INIT}' > $@ ;\
221 | cat $< >> $@ ;\
222 | printf '${BUNDLE_FREE}' >> $@ ;\
223 | cat ${PKG}.tar.gz >> $@ ;\
224 | chmod ugo+x $@ ;\
225 | echo "==> bundle: $@"
226 |
227 |
228 | #####################################################################
229 | ##
230 | ## dependencies
231 | ##
232 | #####################################################################
233 | rebar3:
234 | @echo "==> install rebar (${REBAR})" ;\
235 | curl -L -O -s https://github.com/erlang/rebar3/releases/download/${REBAR}/rebar3 ;\
236 | chmod +x $@
237 |
238 |
--------------------------------------------------------------------------------
/libs/oauth2/include/oauth2.hrl:
--------------------------------------------------------------------------------
1 |
2 | %%
3 | %% Authorization Request
4 | %%
5 | %% https://tools.ietf.org/html/rfc6749#section-4.1.1
6 | -record(authorization, {
7 | response_type = undefined :: binary()
8 | , client_id = undefined :: {iri, binary(), binary()}
9 | , redirect_uri = undefined :: binary()
10 | , scope = undefined :: map()
11 | , state = undefined :: binary()
12 |
13 | %%
14 | %% protocol extension to support UX
15 | , access = undefined :: {iri, binary(), binary()}
16 | , secret = undefined :: binary()
17 | }).
18 |
19 | %%
20 | %% Access Token Request
21 | %%
22 | %% https://tools.ietf.org/html/rfc6749#section-4.1.3
23 | %% https://tools.ietf.org/html/rfc6749#section-4.3.2
24 | %% https://tools.ietf.org/html/rfc6749#section-6
25 | %% password_reset
26 | -record(access_token, {
27 | grant_type = undefined :: binary()
28 | , client_id = undefined :: {iri, binary(), binary()}
29 | %% Note: disabled due to security issue
30 | %% redirect_uri is defined by client reg process
31 | %% redirect_uri = undefined :: binary()
32 | , code = undefined :: binary()
33 |
34 | %%
35 | , username = undefined :: {iri, binary(), binary()}
36 | , password = undefined :: binary()
37 | , scope = undefined :: map()
38 |
39 | , refresh_token = undefined :: binary()
40 | }).
41 |
--------------------------------------------------------------------------------
/libs/oauth2/rebar.config:
--------------------------------------------------------------------------------
1 | {erl_opts, []}.
2 |
3 | {deps, [
4 | datum
5 | , {permit, ".*",
6 | {git, "https://github.com/fogfish/permit", {branch, master}}
7 | }
8 | , {ddb, ".*",
9 | {git, "https://github.com/fogfish/ddb", {branch, master}}
10 | }
11 | ]}.
12 |
13 | {profiles, [
14 | {test, [{deps, [meck]}]}
15 | ]}.
16 |
--------------------------------------------------------------------------------
/libs/oauth2/rebar.lock:
--------------------------------------------------------------------------------
1 | {"1.1.0",
2 | [{<<"base16">>,{pkg,<<"base16">>,<<"1.0.0">>},2},
3 | {<<"base64url">>,{pkg,<<"base64url">>,<<"0.0.1">>},2},
4 | {<<"datum">>,{pkg,<<"datum">>,<<"4.6.1">>},0},
5 | {<<"ddb">>,
6 | {git,"https://github.com/fogfish/ddb",
7 | {ref,"8486e553bd96e2646a1cf27e33c345c2f22ec1d7"}},
8 | 0},
9 | {<<"eini">>,{pkg,<<"eini">>,<<"1.2.6">>},2},
10 | {<<"erlcloud">>,{pkg,<<"erlcloud">>,<<"3.2.12">>},1},
11 | {<<"feta">>,
12 | {git,"https://github.com/fogfish/feta",
13 | {ref,"c5d251b3f995b96afd5e8ec7da61516842aa658c"}},
14 | 1},
15 | {<<"hash">>,
16 | {git,"https://github.com/fogfish/hash",
17 | {ref,"a1b9101189e115b4eabbe941639f3c626614e986"}},
18 | 1},
19 | {<<"jsx">>,{pkg,<<"jsx">>,<<"2.9.0">>},2},
20 | {<<"jwt">>,{pkg,<<"jwt">>,<<"0.1.9">>},1},
21 | {<<"lhttpc">>,{pkg,<<"lhttpc">>,<<"1.6.2">>},2},
22 | {<<"meck">>,{pkg,<<"meck">>,<<"0.8.13">>},2},
23 | {<<"permit">>,
24 | {git,"https://github.com/fogfish/permit",
25 | {ref,"7ace1307d2dd312aa140f3710efdbbba75c8f451"}},
26 | 0},
27 | {<<"pipe">>,{pkg,<<"pipes">>,<<"2.0.1">>},1},
28 | {<<"pipes">>,{pkg,<<"pipes">>,<<"2.0.1">>},1},
29 | {<<"pns">>,
30 | {git,"https://github.com/fogfish/pns",
31 | {ref,"f9b37630d0a8797063a2a443b42f14353ad858c8"}},
32 | 2},
33 | {<<"pts">>,
34 | {git,"https://github.com/fogfish/pts",
35 | {ref,"f98c594f1e596f81b5e54c9791bf69568327fba2"}},
36 | 1},
37 | {<<"uid">>,{pkg,<<"uid">>,<<"1.3.4">>},1}]}.
38 | [
39 | {pkg_hash,[
40 | {<<"base16">>, <<"283644E2B21BD5915ACB7178BED7851FB07C6E5749B8FAD68A53C501092176D9">>},
41 | {<<"base64url">>, <<"36A90125F5948E3AFD7BE97662A1504B934DD5DAC78451CA6E9ABF85A10286BE">>},
42 | {<<"datum">>, <<"93B131203A60CFEA9FFFF6435A50DC24239F689DFEBB76E6AECF6CE689EFE8F4">>},
43 | {<<"eini">>, <<"DFFA48476FD89FB6E41CEEA0ADFA1BC6E7862CCD6584417442F8BB37E5D34715">>},
44 | {<<"erlcloud">>, <<"53B22DC4E080061E9865460D454E5DA47CA28C26F4CB9011A7DE98BFE120D830">>},
45 | {<<"jsx">>, <<"D2F6E5F069C00266CAD52FB15D87C428579EA4D7D73A33669E12679E203329DD">>},
46 | {<<"jwt">>, <<"F5687B0168AE3AA1130E0068C38A7D256E622789BF4724578ADF99A7CE5531DC">>},
47 | {<<"lhttpc">>, <<"044F16F0018C7AA7E945E9E9406C7F6035E0B8BC08BF77B00C78CE260E1071E3">>},
48 | {<<"meck">>, <<"FFEDB39F99B0B99703B8601C6F17C7F76313EE12DE6B646E671E3188401F7866">>},
49 | {<<"pipe">>, <<"A2B56796C63690ED0E78BB77BB389AF250BD70AFA15A6869369DBDC11087D68F">>},
50 | {<<"pipes">>, <<"A2B56796C63690ED0E78BB77BB389AF250BD70AFA15A6869369DBDC11087D68F">>},
51 | {<<"uid">>, <<"42E30E22908E8E2FAA6227E9C261F1954CB540BE3C5A139E112369AE6CC451FC">>}]}
52 | ].
53 |
--------------------------------------------------------------------------------
/libs/oauth2/src/oauth2.app.src:
--------------------------------------------------------------------------------
1 | {application, oauth2,
2 | [
3 | {description, "oauth2 protocol"},
4 | {vsn, "git"},
5 | {modules, []},
6 | {registered, []},
7 | {applications,[
8 | kernel
9 | , stdlib
10 | , datum
11 | , ddb
12 | , permit
13 | ]},
14 | {env, []}
15 | ]
16 | }.
--------------------------------------------------------------------------------
/libs/oauth2/src/oauth2.erl:
--------------------------------------------------------------------------------
1 | -module(oauth2).
2 |
3 | -compile({parse_transform, category}).
4 | -include_lib("include/oauth2.hrl").
5 |
6 | -export([
7 | signup/2
8 | , signin/2
9 | , password/2
10 | , token/2
11 | ]).
12 |
13 | %%
14 | %%
15 | -spec signup(#{}, binary()) -> datum:either(uri:uri()).
16 |
17 | signup(Headers, Request) ->
18 | oauth2_authorize:signup(Headers, Request).
19 |
20 | %%
21 | %%
22 | -spec signin(#{}, binary()) -> datum:either(uri:uri()).
23 |
24 | signin(Headers, Request) ->
25 | oauth2_authorize:signin(Headers, Request).
26 |
27 | %%
28 | %%
29 | -spec password(#{}, binary()) -> datum:either(uri:uri()).
30 |
31 | password(Headers, Request) ->
32 | oauth2_authorize:password(Headers, Request).
33 |
34 | %%
35 | %%
36 | -spec token(#{}, binary()) -> datum:either(#{}).
37 |
38 | token(Headers, Request) ->
39 | oauth2_access:token(Headers, Request).
40 |
--------------------------------------------------------------------------------
/libs/oauth2/src/oauth2_access.erl:
--------------------------------------------------------------------------------
1 | %%
2 | %% @doc
3 | %% access token request
4 | %% https://tools.ietf.org/html/rfc6749#section-4.1.3
5 | -module(oauth2_access).
6 |
7 | -compile({parse_transform, category}).
8 | -include_lib("include/oauth2.hrl").
9 | -include_lib("permit/src/permit.hrl").
10 |
11 | -export([
12 | token/2
13 | ]).
14 |
15 | %%
16 | %%
17 | -spec token(#{}, binary()) -> datum:either(#{}).
18 |
19 | token(Headers, Request)
20 | when is_binary(Request) ->
21 | req_token_auth(Headers, lens:get(oauth2_codec:access_token(), oauth2_codec:decode(Request))).
22 |
23 | %%
24 | req_token_auth(#{<<"Authorization">> := Digest}, Request) ->
25 | [either ||
26 | #{
27 | <<"client_jwt">> := Identity
28 | } <- oauth2_client:confidential(Digest),
29 | req_token(Request#access_token{client_id = Identity})
30 | ];
31 |
32 | req_token_auth(_, #access_token{client_id = Client} = Request) ->
33 | [either ||
34 | oauth2_client:public(Client),
35 | req_token(Request),
36 | cats:unit(maps:remove(<<"refresh_token">>, _))
37 | ].
38 |
39 | req_token(#access_token{
40 | grant_type = <<"authorization_code">>
41 | , code = Code
42 | }) ->
43 | [either ||
44 | permit:include(Code, #{<<"aud">> => <<"oauth2">>}),
45 | #{<<"app">> := Encoded} <- permit:equals(Code, #{}),
46 | Scopes <- cats:unit(jsx:decode(base64url:decode(Encoded), [return_maps])),
47 | Access <- permit:revocable(Code, 3600, Scopes), %% TODO: configurable ttl
48 | Refresh <- oauth2_authorize:exchange_code(Code, Scopes),
49 | Claims <- permit:claims(Access),
50 | cats:unit(
51 | maps:merge(Claims,
52 | #{
53 | <<"token_type">> => <<"bearer">>,
54 | <<"expires_in">> => 3600,
55 | <<"access_token">> => Access,
56 | <<"refresh_token">> => Refresh
57 | }
58 | )
59 | )
60 | ];
61 |
62 | req_token(#access_token{
63 | grant_type = <<"password">>
64 | , username = {iri, _, _} = Access
65 | , password = Secret
66 | , scope = Claims
67 | }) ->
68 | [either ||
69 | Token <- permit:revocable(Access, Secret, 3600, Claims), %% TODO: configurable ttl
70 | Refresh <- oauth2_authorize:exchange_code(Token, Claims),
71 | cats:unit(
72 | maps:merge(Claims,
73 | #{
74 | <<"token_type">> => <<"bearer">>,
75 | <<"expires_in">> => 3600,
76 | <<"access_token">> => Token,
77 | <<"refresh_token">> => Refresh
78 | }
79 | )
80 | )
81 | ];
82 |
83 | req_token(#access_token{
84 | grant_type = <<"client_credentials">>
85 | , client_id = Identity
86 | , scope = Claims
87 | }) when is_binary(Identity) ->
88 | [either ||
89 | permit:revocable(Identity, 3600, Claims), %% TODO: configurable ttl
90 | cats:unit(
91 | maps:merge(Claims,
92 | #{
93 | <<"token_type">> => <<"bearer">>,
94 | <<"expires_in">> => 3600,
95 | <<"access_token">> => _
96 | }
97 | )
98 | )
99 | ];
100 |
101 | req_token(#access_token{
102 | grant_type = <<"refresh_token">>
103 | , client_id = Identity
104 | , refresh_token = Code
105 | }) when is_binary(Identity) ->
106 | [either ||
107 | permit:include(Code, #{<<"aud">> => <<"oauth2">>}),
108 | #{<<"app">> := Encoded} <- permit:equals(Code, #{}),
109 | Claims <- cats:unit(jsx:decode(base64url:decode(Encoded), [return_maps])),
110 | Access <- permit:revocable(Code, 3600, Claims), %% TODO: configurable ttl
111 | Refresh <- oauth2_authorize:exchange_code(Code, Claims),
112 | cats:unit(
113 | maps:merge(Claims,
114 | #{
115 | <<"token_type">> => <<"bearer">>,
116 | <<"expires_in">> => 3600,
117 | <<"access_token">> => Access,
118 | <<"refresh_token">> => Refresh
119 | }
120 | )
121 | )
122 | ];
123 |
124 | req_token(_) ->
125 | {error, invalid_request}.
126 |
--------------------------------------------------------------------------------
/libs/oauth2/src/oauth2_authorize.erl:
--------------------------------------------------------------------------------
1 | %%
2 | %% @doc
3 | %% authorization request
4 | %% https://tools.ietf.org/html/rfc6749#section-4.1.1
5 | %% https://tools.ietf.org/html/rfc6749#section-4.2.1
6 | -module(oauth2_authorize).
7 |
8 | -compile({parse_transform, category}).
9 | -include_lib("include/oauth2.hrl").
10 | -include_lib("permit/src/permit.hrl").
11 |
12 |
13 | -export([
14 | signup/2
15 | , signin/2
16 | , password/2
17 | , exchange_code/2
18 | ]).
19 |
20 | %%
21 | %%
22 | -spec signup(#{}, binary()) -> datum:either(uri:uri()).
23 |
24 | signup(Headers, Request)
25 | when is_binary(Request) ->
26 | req_signup_auth(Headers, lens:get(oauth2_codec:authorization(), oauth2_codec:decode(Request))).
27 |
28 | %%
29 | req_signup_auth(#{<<"Authorization">> := Digest}, #authorization{redirect_uri = RedirectAddOn} = Request) ->
30 | [either ||
31 | #{
32 | <<"client_id">> := ClientId,
33 | <<"redirect_uri">> := RedirectBase
34 | } <- oauth2_client:confidential(Digest),
35 | req_signup(
36 | redirect_uri(RedirectBase, RedirectAddOn),
37 | Request#authorization{client_id = ClientId}
38 | )
39 | ];
40 |
41 | req_signup_auth(_, #authorization{client_id = Client, redirect_uri = RedirectAddOn} = Request) ->
42 | [either ||
43 | #{<<"redirect_uri">> := RedirectBase} <- oauth2_client:public(Client),
44 | req_signup(redirect_uri(RedirectBase, RedirectAddOn), Request)
45 | ].
46 |
47 | %%
48 | req_signup(Redirect, #authorization{
49 | response_type = <<"code">>
50 | , access = {iri, _, _} = Access
51 | , secret = Secret
52 | , scope = Claims
53 | , state = State
54 | }) ->
55 | case
56 | [either ||
57 | permit:create(Access, Secret, Claims),
58 | exchange_code(_, Claims)
59 | ]
60 | of
61 | {ok, Code} ->
62 | {ok, uri:q([{code, Code}, {state, State}], Redirect)};
63 | {error, Reason} ->
64 | {ok, uri:q([{error, Reason}, {state, State}], Redirect)}
65 | end;
66 |
67 | req_signup(Redirect, #authorization{
68 | response_type = <<"token">>
69 | , access = {iri, _, _} = Access
70 | , secret = Secret
71 | , scope = Claims
72 | , state = State
73 | }) ->
74 | case
75 | [either ||
76 | permit:create(Access, Secret, Claims),
77 | permit:revocable(_, 3600, Claims) %% TODO: configurable ttl
78 | ]
79 | of
80 | {ok, Token} ->
81 | {ok, uri:q([{access_token, Token}, {expires_in, 3600}, {state, State}], Redirect)};
82 | {error, Reason} ->
83 | {ok, uri:q([{error, Reason}, {state, State}], Redirect)}
84 | end;
85 |
86 | req_signup(_, _) ->
87 | {error, invalid_request}.
88 |
89 | %%
90 | %%
91 | -spec signin(#{}, binary()) -> datum:either(uri:uri()).
92 |
93 | signin(Headers, Request)
94 | when is_binary(Request) ->
95 | req_signin_auth(Headers, lens:get(oauth2_codec:authorization(), oauth2_codec:decode(Request))).
96 |
97 | %%
98 | req_signin_auth(#{<<"Authorization">> := Digest}, #authorization{redirect_uri = RedirectAddOn} = Request) ->
99 | [either ||
100 | #{
101 | <<"client_id">> := ClientId,
102 | <<"redirect_uri">> := RedirectBase
103 | } <- oauth2_client:confidential(Digest),
104 | req_signin(
105 | redirect_uri(RedirectBase, RedirectAddOn),
106 | Request#authorization{client_id = ClientId}
107 | )
108 | ];
109 |
110 | req_signin_auth(_, #authorization{client_id = Client, redirect_uri = RedirectAddOn} = Request) ->
111 | [either ||
112 | #{<<"redirect_uri">> := RedirectBase} <- oauth2_client:public(Client),
113 | req_signin(redirect_uri(RedirectBase, RedirectAddOn), Request)
114 | ].
115 |
116 | req_signin(Redirect, #authorization{
117 | response_type = <<"code">>
118 | , access = {iri, _, _} = Access
119 | , secret = Secret
120 | , scope = Claims
121 | , state = State
122 | }) ->
123 | case
124 | [either ||
125 | permit:stateless(Access, Secret, 3600, Claims),
126 | exchange_code(_, Claims)
127 | ]
128 | of
129 | {ok, Code} ->
130 | {ok, uri:q([{code, Code}, {state, State}], Redirect)};
131 | {error, Reason} ->
132 | {ok, uri:q([{error, Reason}, {state, State}], Redirect)}
133 | end;
134 |
135 | req_signin(Redirect, #authorization{
136 | response_type = <<"token">>
137 | , access = {iri, _, _} = Access
138 | , secret = Secret
139 | , scope = Claims
140 | , state = State
141 | }) ->
142 | case
143 | permit:revocable(Access, Secret, 3600, Claims) %% TODO: configurable ttl
144 | of
145 | {ok, Token} ->
146 | {ok, uri:q([{access_token, Token}, {expires_in, 3600}, {state, State}], Redirect)};
147 | {error, Reason} ->
148 | {ok, uri:q([{error, Reason}, {state, State}], Redirect)}
149 | end;
150 |
151 | req_signin(_, _) ->
152 | {error, invalid_request}.
153 |
154 | %%
155 | %%
156 | -spec password(#{}, binary()) -> datum:either(#{}).
157 |
158 | password(Headers, Request)
159 | when is_binary(Request) ->
160 | req_password_auth(Headers, lens:get(oauth2_codec:authorization(), oauth2_codec:decode(Request))).
161 |
162 | req_password_auth(#{<<"Authorization">> := Digest}, Request) ->
163 | [either ||
164 | #{
165 | <<"client_jwt">> := Identity
166 | } <- oauth2_client:confidential(Digest),
167 | req_password(Request#authorization{client_id = Identity})
168 | ];
169 |
170 | req_password_auth(_, #authorization{client_id = Client} = Request) ->
171 | [either ||
172 | oauth2_client:public(Client),
173 | req_password(Request)
174 | ].
175 |
176 | req_password(#authorization{
177 | response_type = <<"password_reset">>
178 | , client_id = Client
179 | , redirect_uri = RedirectAddOn
180 | , access = Access
181 | }) ->
182 | [either ||
183 | #pubkey{claims = Claims} <- permit:lookup(Access),
184 | permit:update(Access, crypto:strong_rand_bytes(30), Claims),
185 | permit:stateless(_, 3600, #{<<"aud">> => <<"oauth2">>, <<"app">> => <<"password">>}),
186 | req_reset_link(Client, Access, _),
187 | oauth2_email:password_reset(Access, _),
188 | permit:as_access(Client),
189 | cats:unit(
190 | uri:q([{client_id, _}, {redirect_uri, RedirectAddOn}],
191 | uri:path(<<"/oauth2/authorize">>,
192 | uri:new(permit_config:iss())
193 | )
194 | )
195 | )
196 | ];
197 |
198 | req_password(#authorization{
199 | response_type = <<"password_recover">>
200 | , client_id = Client
201 | , redirect_uri = RedirectAddOn
202 | , state = Code
203 | , secret = Secret
204 | }) ->
205 | [either ||
206 | permit:include(Code, #{<<"aud">> => <<"oauth2">>, <<"app">> => <<"password">>}),
207 | cats:unit(lens:get(lens:at(<<"sub">>), _)),
208 | Access <- permit:to_access(_),
209 | #pubkey{claims = Claims} <- permit:lookup(Access),
210 | permit:update(Access, Secret, Claims),
211 | #pubkey{claims = #{<<"redirect_uri">> := RedirectBase}} <- permit:lookup(Client),
212 | exchange_code(Access, Secret, Claims),
213 | cats:unit(uri:q([{code, _}], redirect_uri(RedirectBase, RedirectAddOn)))
214 | ].
215 |
216 |
217 | req_reset_link(Client, Access, Code) ->
218 | [either ||
219 | ClientId <- permit:as_access(Client),
220 | AccessId <- permit:as_access(Access),
221 | cats:unit(
222 | uri:anchor(<<"recover">>,
223 | uri:q([{client_id, ClientId}, {access, AccessId}, {code, Code}],
224 | uri:path(<<"/oauth2/authorize">>,
225 | uri:new(permit_config:iss())
226 | )
227 | )
228 | )
229 | )
230 | ].
231 |
232 | %%
233 | %%
234 | exchange_code(Token, Claims) ->
235 | %% TODO: configurable ttl
236 | permit:stateless(Token, 3600, #{
237 | <<"aud">> => <<"oauth2">>
238 | , <<"app">> => base64url:encode(jsx:encode(Claims))
239 | }).
240 |
241 | exchange_code(Access, Secret, Claims) ->
242 | %% TODO: configurable ttl
243 | permit:stateless(Access, Secret, 3600, #{
244 | <<"aud">> => <<"oauth2">>
245 | , <<"app">> => base64url:encode(jsx:encode(Claims))
246 | }).
247 |
248 | %%
249 | %%
250 | redirect_uri(Base, undefined) ->
251 | uri:new(Base);
252 | redirect_uri(Base, <<>>) ->
253 | uri:new(Base);
254 | redirect_uri(Base, AddOn) ->
255 | UriBase = uri:new(Base),
256 | UriAddOn = uri:new(AddOn),
257 | Segments = uri:segments(UriBase) ++ only_ascii(uri:segments(UriAddOn)),
258 | uri:anchor(
259 | uri:anchor(UriAddOn),
260 | uri:segments(Segments, UriBase)
261 | ).
262 |
263 | only_ascii(Segments) ->
264 | lists:filter(
265 | fun(X) -> X /= <<>> end,
266 | lists:map(
267 | fun(Segment) ->
268 | << <> || <> <= Segment, is_ascii(C)>>
269 | end,
270 | Segments
271 | )
272 | ).
273 |
274 | is_ascii(X) when X >= $0 andalso X =< $9 -> true;
275 | is_ascii(X) when X >= $A andalso X =< $Z -> true;
276 | is_ascii(X) when X >= $a andalso X =< $z -> true;
277 | is_ascii(_) -> false.
--------------------------------------------------------------------------------
/libs/oauth2/src/oauth2_client.erl:
--------------------------------------------------------------------------------
1 | %% @doc
2 | %% Client Authentication
3 | %% https://tools.ietf.org/html/rfc6749#section-2.3
4 | -module(oauth2_client).
5 |
6 | -compile({parse_transform, category}).
7 | -include_lib("include/oauth2.hrl").
8 | -include_lib("permit/src/permit.hrl").
9 |
10 | -export([
11 | public/1
12 | , confidential/1
13 | , create/2
14 | , lookup/1
15 | , remove/2
16 | ]).
17 |
18 | %%
19 | %% data types
20 | -type digest() :: binary().
21 |
22 | -define(CLAIMS, [<<"app">>, <<"redirect_uri">>, <<"security">>]).
23 |
24 | %%
25 | %%
26 | -spec public(digest()) -> datum:either(permit:claims()).
27 |
28 | public(Access)
29 | when is_binary(Access) ->
30 | [either || permit:to_access(Access), public(_)];
31 |
32 | public({iri, _, <<"account@oauth2">>} = Access) ->
33 | %% Note: account@oauth2 is built-in expereince
34 | %% the not_found fallback simplifies management of clients registrations
35 | case
36 | [either ||
37 | permit:lookup(Access),
38 | permit:include(_, #{<<"security">> => <<"public">>})
39 | ]
40 | of
41 | {error, not_found} ->
42 | public_default();
43 | {ok, Client} ->
44 | {ok, Client#{<<"redirect_uri">> => public_default_redirect()}}
45 | end;
46 |
47 | public({iri, _, _} = Access) ->
48 | [either ||
49 | permit:lookup(Access),
50 | permit:include(_, #{<<"security">> => <<"public">>})
51 | ].
52 |
53 | public_default() ->
54 | [either ||
55 | Spec =< #{
56 | <<"security">> => <<"public">>,
57 | <<"redirect_uri">> => public_default_redirect()
58 | },
59 | permit:to_access(<<"account@oauth2">>),
60 | permit:create(_, crypto:strong_rand_bytes(30), Spec),
61 | cats:unit(Spec)
62 | ].
63 |
64 | public_default_redirect() ->
65 | uri:s(uri:path("/oauth2/account", uri:new(permit_config:iss()))).
66 |
67 | %%
68 | %%
69 | -spec confidential(digest()) -> datum:either(permit:claims()).
70 |
71 | confidential(<<"Basic ", Digest/binary>>) ->
72 | [either ||
73 | [Access, Secret] <- cats:unit(binary:split(base64:decode(Digest), <<$:>>)),
74 | Identity <- permit:to_access(Access),
75 | #pubkey{claims = Claims} = Spec <- permit:lookup(Identity),
76 | Stateless <- permit:stateless(Identity, Secret, 10, Claims),
77 | permit:include(Spec, #{<<"security">> => <<"confidential">>}),
78 | cats:unit(_#{
79 | <<"client_id">> => Identity
80 | , <<"client_jwt">> => Stateless
81 | })
82 | ].
83 |
84 | %%
85 | create(Jwt, Claims) ->
86 | [either ||
87 | #{<<"sub">> := Master} <- permit:validate(Jwt),
88 | Spec <- claims(Claims),
89 | {Access, Secret} <- permit:pubkey(Master, Spec),
90 | permit:as_access(Access),
91 | cats:unit(#{access => _, secret => Secret})
92 | ].
93 |
94 | %%
95 | claims(Claims) ->
96 | [either ||
97 | cats:unit(maps:with(?CLAIMS, Claims)),
98 | claims_security(_),
99 | claims_redirect_uri(_)
100 | ].
101 |
102 | claims_security(#{<<"security">> := <<"public">>} = Claims) ->
103 | {ok, Claims};
104 | claims_security(#{<<"security">> := <<"confidential">>} = Claims) ->
105 | {ok, Claims};
106 | claims_security(_) ->
107 | {error, invalid_security_profile}.
108 |
109 | claims_redirect_uri(#{<<"redirect_uri">> := Uri} = Profile) ->
110 | [either ||
111 | cats:unit( uri:new(Uri) ),
112 | is_some(fun uri:schema/1, _),
113 | is_some(fun uri:authority/1, _),
114 | is_some(fun uri:path/1, _),
115 | is_none(fun uri:q/1, _),
116 | is_none(fun uri:anchor/1, _),
117 | cats:unit(Profile)
118 | ].
119 |
120 | is_some(Fun, Uri) ->
121 | case Fun(Uri) of
122 | undefined ->
123 | {error, invalid_uri};
124 | {<<>>, undefined} ->
125 | {error, invalid_uri};
126 | _ ->
127 | {ok, Uri}
128 | end.
129 |
130 | is_none(Fun, Uri) ->
131 | case Fun(Uri) of
132 | undefined ->
133 | {ok, Uri};
134 | _ ->
135 | {error, invalid_uri}
136 | end.
137 |
138 | %%
139 | lookup(Jwt) ->
140 | [either ||
141 | #{<<"sub">> := Master} <- permit:validate(Jwt),
142 | permit_pubkey_db:keys(Master),
143 | cats:unit(encode_clients(_))
144 | ].
145 |
146 | encode_clients({PubKeys, _}) ->
147 | [ Claim#{<<"access">> => erlang:element(2, permit:as_access(Access))}
148 | || #pubkey{
149 | id = Access,
150 | claims = #{
151 | <<"security">> := _
152 | , <<"redirect_uri">> := _
153 | } = Claim
154 | } <- PubKeys
155 | ].
156 |
157 | %%
158 | remove(Jwt, Client) ->
159 | [either ||
160 | {iri, IDP, _} = Access <- permit:to_access(Client),
161 | #{<<"sub">> := {iri, IDP, _}} <- permit:validate(Jwt),
162 | permit:revoke(Access),
163 | cats:unit(#{access => Client})
164 | ].
165 |
--------------------------------------------------------------------------------
/libs/oauth2/src/oauth2_codec.erl:
--------------------------------------------------------------------------------
1 | -module(oauth2_codec).
2 |
3 | -include_lib("include/oauth2.hrl").
4 | -compile({parse_transform, category}).
5 | -compile({parse_transform, generic}).
6 |
7 | -export([
8 | decode/1
9 | , authorization/0
10 | , access_token/0
11 | ]).
12 |
13 | %%
14 | %%
15 | -spec decode(binary()) -> #{}.
16 |
17 | decode(Request) ->
18 | [identity ||
19 | binary:split(Request, <<$&>>, [trim, global]),
20 | lists:map(fun as_pair/1, _),
21 | maps:from_list(_)
22 | ].
23 |
24 | as_pair(Pair) ->
25 | erlang:list_to_tuple(
26 | [uri:unescape(X) || X <- binary:split(Pair, <<$=>>)]
27 | ).
28 |
29 | %%
30 | %%
31 | -spec authorization() -> lens:lens().
32 |
33 | authorization() ->
34 | labelled:lens(
35 | #authorization{
36 | client_id = iri()
37 | , scope = scope()
38 | , access = iri()
39 | }
40 | ).
41 |
42 | %%
43 | %%
44 | -spec access_token() -> lens:lens().
45 |
46 | access_token() ->
47 | labelled:lens(
48 | #access_token{
49 | client_id = iri()
50 | , username = iri()
51 | , scope = scope()
52 | }
53 | ).
54 |
55 | %%
56 | iri() ->
57 | fun(Fun, IRI) ->
58 | lens:fmap(fun(X) -> X end, Fun(iri(IRI)))
59 | end.
60 |
61 | iri(undefined) ->
62 | undefined;
63 | iri(IRI) ->
64 | {ok, Access} = permit:to_access(IRI),
65 | Access.
66 |
67 | %%
68 | scope() ->
69 | fun(Fun, Scope) ->
70 | lens:fmap(fun(X) -> X end, Fun(scope(Scope)))
71 | end.
72 |
73 | scope(undefined) ->
74 | #{};
75 | scope(Scope) ->
76 | decode(uri:unescape(Scope)).
77 |
--------------------------------------------------------------------------------
/libs/oauth2/src/oauth2_email.erl:
--------------------------------------------------------------------------------
1 | -module(oauth2_email).
2 | -compile({parse_transform, category}).
3 |
4 | -export([
5 | password_reset/2
6 | ]).
7 |
8 | password_reset(Access, Link) ->
9 | try
10 | [either ||
11 | Email <- permit:as_access(Access),
12 | erlcloud_aws:auto_config(),
13 | erlcloud_ses:send_email(
14 | Email,
15 | [{html, [
16 | {charset, <<"utf-8">>},
17 | {data, password_reset_email(Email, Link)}
18 | ]}],
19 | "Password Recovery",
20 | typecast:s(os:getenv("OAUTH2_EMAIL")),
21 | _
22 | )
23 | ]
24 | catch E:R ->
25 | serverless:error(E),
26 | serverless:error(R),
27 | ok
28 | end.
29 |
30 | password_reset_email(Access, Link) ->
31 | io_lib:format("
32 | Hello,
33 |
34 | You recently requested to reset your password for your account ~s at ~s.
35 | Click the link below to reset it.
36 |
37 | Reset Your Password
38 | If you did not request a password reset, please ignore this email or reply to let us know. This password reset is only valid for the next few minutes.
39 | Thanks,
40 | ~s team
41 |
",
42 | [
43 | Access,
44 | permit_config:iss(),
45 | uri:s(Link),
46 | typecast:s(os:getenv("OAUTH2_EMAIL_SIGNATURE"))
47 | ]
48 | ).
--------------------------------------------------------------------------------
/libs/oauth2/test/oauth2_FIXTURES.erl:
--------------------------------------------------------------------------------
1 | -module(oauth2_FIXTURES).
2 |
3 | -export([
4 | client_default/0
5 | , client_public/0
6 | , client_confidential/0
7 | , joe/0
8 | ]).
9 |
10 | %%
11 | client_default() ->
12 | [
13 | {iri, <<"Jaba3STOV1Ioz1GrJ-W_wA">>, <<"account@oauth2">>}
14 | , undefined
15 | , #{
16 | <<"security">> => <<"public">>,
17 | <<"redirect_uri">> => <<"https://example.com/oauth2/account">>
18 | }
19 | ].
20 |
21 | %%
22 | client_public() ->
23 | [
24 | {iri, <<"mz_riE1VVY7WvBJLdbDygw">>, <<"public@org">>}
25 | , <<"secret">>
26 | , #{
27 | <<"security">> => <<"public">>,
28 | <<"redirect_uri">> => <<"https://example.com/public">>
29 | }
30 | ].
31 |
32 | %%
33 | client_confidential() ->
34 | [
35 | {iri, <<"KdGtZCcUFSHGwNOWY7deAg">>, <<"confidential@org">>}
36 | , <<"secret">>
37 | , #{
38 | <<"security">> => <<"confidential">>,
39 | <<"redirect_uri">> => <<"https://example.com/confidential">>
40 | }
41 | ].
42 |
43 | %%
44 | joe() ->
45 | [
46 | {iri, <<"lxQBL4BKinfHjtnPvWIONw">>, <<"joe@org">>}
47 | , <<"secret">>
48 | , #{
49 | <<"rd">> => <<"api">>,
50 | <<"wr">> => <<"ddb">>
51 | }
52 | ].
53 |
--------------------------------------------------------------------------------
/libs/oauth2/test/oauth2_SUITE.erl:
--------------------------------------------------------------------------------
1 | -module(oauth2_SUITE).
2 | -include_lib("common_test/include/ct.hrl").
3 | -include_lib("oauth2/include/oauth2.hrl").
4 |
5 | -export([all/0]).
6 | -compile(export_all).
7 |
8 | all() ->
9 | [Test || {Test, NAry} <- ?MODULE:module_info(exports),
10 | Test =/= module_info,
11 | Test =/= init_per_suite,
12 | Test =/= end_per_suite,
13 | Test =/= access,
14 | NAry =:= 1
15 | ].
16 |
17 | %%%----------------------------------------------------------------------------
18 | %%%
19 | %%% init
20 | %%%
21 | %%%----------------------------------------------------------------------------
22 | init_per_suite(Config) ->
23 | os:putenv("PERMIT_ISSUER", "https://example.com"),
24 | os:putenv("PERMIT_AUDIENCE", "suite"),
25 | os:putenv("PERMIT_CLAIMS", "rd=none&wr=none"),
26 | {ok, _} = application:ensure_all_started(oauth2),
27 | erlang:apply(permit, create, oauth2_FIXTURES:client_public()),
28 | erlang:apply(permit, create, oauth2_FIXTURES:client_confidential()),
29 | erlang:apply(permit, create, oauth2_FIXTURES:joe()),
30 | Config.
31 |
32 |
33 | end_per_suite(_Config) ->
34 | application:stop(permit),
35 | ok.
36 |
37 | %%%----------------------------------------------------------------------------
38 | %%%
39 | %%% unit tests
40 | %%%
41 | %%%----------------------------------------------------------------------------
42 |
43 | %%
44 | digest() ->
45 | #{<<"Authorization">> => <<"Basic ", (base64:encode(<<"confidential@org:secret">>))/binary>>}.
46 |
47 | access(Suffix) ->
48 | Prefix = base64url:encode(crypto:hash(md5, Suffix)),
49 | {iri, Prefix, Suffix}.
50 |
51 | %%
52 | signup_code_public(_) ->
53 | Request = <<"response_type=code&client_id=public@org&access=joe.c.p@org&secret=secret&scope=rd%3Dapi%26wr%3Dddb">>,
54 | {ok, {uri, https, _} = Uri} = oauth2:signup(#{}, Request),
55 | ct:pal("==> ~p~n", [Uri]),
56 | <<"example.com">> = uri:host(Uri),
57 | <<"/public">> = uri:path(Uri),
58 | authorization_code_check(access(<<"joe.c.p@org">>), Uri).
59 |
60 | %%
61 | signin_code_public(_) ->
62 | Request = <<"response_type=code&client_id=public@org&access=joe@org&secret=secret&scope=rd%3Dapi%26wr%3Dddb">>,
63 | {ok, {uri, https, _} = Uri} = oauth2:signin(#{}, Request),
64 | <<"example.com">> = uri:host(Uri),
65 | <<"/public">> = uri:path(Uri),
66 | authorization_code_check(access(<<"joe@org">>), Uri).
67 |
68 | %%
69 | signup_code_confidential(_) ->
70 | Request = <<"response_type=code&access=joe.c.c@org&secret=secret&scope=rd%3Dapi%26wr%3Dddb">>,
71 | {ok, {uri, https, _} = Uri} = oauth2:signup(digest(), Request),
72 | <<"example.com">> = uri:host(Uri),
73 | <<"/confidential">> = uri:path(Uri),
74 | authorization_code_check(access(<<"joe.c.c@org">>), Uri).
75 |
76 | %%
77 | signin_code_confidential(_) ->
78 | Request = <<"response_type=code&access=joe.c.c@org&secret=secret&scope=rd%3Dapi%26wr%3Dddb">>,
79 | {ok, {uri, https, _} = Uri} = oauth2:signin(digest(), Request),
80 | <<"example.com">> = uri:host(Uri),
81 | <<"/confidential">> = uri:path(Uri),
82 | authorization_code_check(access(<<"joe.c.c@org">>), Uri).
83 |
84 | authorization_code_check({iri, IDP, _} = Access, Uri) ->
85 | Code = uri:q(<<"code">>, undefined, Uri),
86 | {ok, #{
87 | <<"iss">> := <<"https://example.com">>
88 | , <<"aud">> := <<"oauth2">>
89 | , <<"idp">> := IDP
90 | , <<"exp">> := _
91 | , <<"tji">> := _
92 | , <<"sub">> := Access
93 | }} = permit:validate(Code),
94 | {ok, _} = permit:equals(Code, #{}).
95 |
96 | %%
97 | signup_implicit_public(_) ->
98 | Request = <<"response_type=token&client_id=public@org&access=joe.i.p@org&secret=secret&scope=rd%3Dapi%26wr%3Dddb">>,
99 | {ok, {uri, https, _} = Uri} = oauth2:signup(#{}, Request),
100 | <<"example.com">> = uri:host(Uri),
101 | <<"/public">> = uri:path(Uri),
102 | access_token_check(access(<<"joe.i.p@org">>), Uri).
103 |
104 | %%
105 | signin_implicit_public(_) ->
106 | Request = <<"response_type=token&client_id=public@org&access=joe@org&secret=secret&scope=rd%3Dapi%26wr%3Dddb">>,
107 | {ok, {uri, https, _} = Uri} = oauth2:signin(#{}, Request),
108 | <<"example.com">> = uri:host(Uri),
109 | <<"/public">> = uri:path(Uri),
110 | access_token_check(access(<<"joe@org">>), Uri).
111 |
112 | %%
113 | signup_implicit_confidential(_) ->
114 | Request = <<"response_type=token&access=joe.i.c@org&secret=secret&scope=rd%3Dapi%26wr%3Dddb">>,
115 | {ok, {uri, https, _} = Uri} = oauth2:signup(digest(), Request),
116 | <<"example.com">> = uri:host(Uri),
117 | <<"/confidential">> = uri:path(Uri),
118 | access_token_check(access(<<"joe.i.c@org">>), Uri).
119 |
120 | %%
121 | signin_implicit_confidential(_) ->
122 | Request = <<"response_type=token&access=joe@org&secret=secret&scope=rd%3Dapi%26wr%3Dddb">>,
123 | {ok, {uri, https, _} = Uri} = oauth2:signin(digest(), Request),
124 | <<"example.com">> = uri:host(Uri),
125 | <<"/confidential">> = uri:path(Uri),
126 | access_token_check(access(<<"joe@org">>), Uri).
127 |
128 | access_token_check({iri, IDP, _} = Access, Uri) ->
129 | Code = uri:q(<<"access_token">>, undefined, Uri),
130 | {ok, #{
131 | <<"iss">> := <<"https://example.com">>
132 | , <<"aud">> := <<"suite">>
133 | , <<"idp">> := IDP
134 | , <<"exp">> := _
135 | , <<"tji">> := _
136 | , <<"sub">> := Access
137 | }} = permit:validate(Code),
138 | {ok, _} = permit:equals(Code, #{<<"rd">> => <<"api">>, <<"wr">> => <<"ddb">>}).
139 |
140 | %%
141 | signup_conflict_code(_) ->
142 | Request = <<"response_type=code&client_id=public@org&access=joe@org&secret=secret&scope=rd%3Dapi%26wr%3Dddb">>,
143 | {ok, {uri, https, _} = Uri} = oauth2:signup(#{}, Request),
144 | <<"example.com">> = uri:host(Uri),
145 | <<"/public">> = uri:path(Uri),
146 | <<"conflict">> = uri:q(<<"error">>, undefined, Uri).
147 |
148 | %%
149 | signup_conflict_implicit(_) ->
150 | Request = <<"response_type=token&client_id=public@org&access=joe@org&secret=secret&scope=rd%3Dapi%26wr%3Dddb">>,
151 | {ok, {uri, https, _} = Uri} = oauth2:signup(#{}, Request),
152 | <<"example.com">> = uri:host(Uri),
153 | <<"/public">> = uri:path(Uri),
154 | <<"conflict">> = uri:q(<<"error">>, undefined, Uri).
155 |
156 | %%
157 | signup_unknown_public(_) ->
158 | Request = <<"response_type=code&client_id=unknown@org&access=joe@org&secret=secret&scope=rd%3Dapi%26wr%3Dddb">>,
159 | {error, not_found} = oauth2:signup(#{}, Request).
160 |
161 | %%
162 | signin_unknown_public(_) ->
163 | Request = <<"response_type=code&client_id=unknown@org&access=joe@org&secret=secret&scope=rd%3Dapi%26wr%3Dddb">>,
164 | {error, not_found} = oauth2:signin(#{}, Request).
165 |
166 | %%
167 | signup_unknown_confidential(_) ->
168 | Request = <<"response_type=code&access=joe@org&secret=secret&scope=rd%3Dapi%26wr%3Dddb">>,
169 | {error, not_found} = oauth2:signup(
170 | #{<<"Authorization">> => <<"Basic ", (base64:encode(<<"unknown@org:secret">>))/binary>>},
171 | Request
172 | ).
173 |
174 | %%
175 | signin_unknown_confidential(_) ->
176 | Request = <<"response_type=code&access=joe@org&secret=secret&scope=rd%3Dapi%26wr%3Dddb">>,
177 | {error, not_found} = oauth2:signin(
178 | #{<<"Authorization">> => <<"Basic ", (base64:encode(<<"unknown@org:secret">>))/binary>>},
179 | Request
180 | ).
181 |
182 | %%
183 | signin_not_found_code(_) ->
184 | Request = <<"response_type=code&client_id=public@org&access=unknown@org&secret=secret&scope=rd%3Dapi%26wr%3Dddb">>,
185 | {ok, {uri, https, _} = Uri} = oauth2:signin(#{}, Request),
186 | <<"example.com">> = uri:host(Uri),
187 | <<"/public">> = uri:path(Uri),
188 | <<"not_found">> = uri:q(<<"error">>, undefined, Uri).
189 |
190 | %%
191 | signin_not_found_implicit(_) ->
192 | Request = <<"response_type=token&client_id=public@org&access=unknown@org&secret=secret&scope=rd%3Dapi%26wr%3Dddb">>,
193 | {ok, {uri, https, _} = Uri} = oauth2:signin(#{}, Request),
194 | <<"example.com">> = uri:host(Uri),
195 | <<"/public">> = uri:path(Uri),
196 | <<"not_found">> = uri:q(<<"error">>, undefined, Uri).
197 |
198 | %%
199 | signin_escalation_attack_code(_) ->
200 | Request = <<"response_type=code&client_id=public@org&access=joe@org&secret=secret&scope=rd%3Dapi%26wr%3Dall">>,
201 | {ok, {uri, https, _} = Uri} = oauth2:signin(#{}, Request),
202 | <<"example.com">> = uri:host(Uri),
203 | <<"/public">> = uri:path(Uri),
204 | <<"forbidden">> = uri:q(<<"error">>, undefined, Uri).
205 |
206 | %%
207 | signin_escalation_attack_implicit(_) ->
208 | Request = <<"response_type=token&client_id=public@org&access=joe@org&secret=secret&scope=rd%3Dapi%26wr%3Dall">>,
209 | {ok, {uri, https, _} = Uri} = oauth2:signin(#{}, Request),
210 | <<"example.com">> = uri:host(Uri),
211 | <<"/public">> = uri:path(Uri),
212 | <<"forbidden">> = uri:q(<<"error">>, undefined, Uri).
213 |
214 | %%
215 | %%
216 | access_token_code_public(_) ->
217 | RequestA = <<"response_type=code&client_id=public@org&access=joe@org&secret=secret&scope=rd%3Dapi%26wr%3Dddb">>,
218 | {ok, {uri, https, _} = Uri} = oauth2:signin(#{}, RequestA),
219 | Code = uri:q(<<"code">>, undefined, Uri),
220 | RequestB = <<"grant_type=authorization_code&client_id=public@org&code=", Code/binary>>,
221 | {ok, #{
222 | <<"token_type">> := <<"bearer">>
223 | , <<"expires_in">> := _
224 | , <<"access_token">> := _
225 | , <<"rd">> := <<"api">>
226 | , <<"wr">> := <<"ddb">>
227 | } = AccessToken } = oauth2:token(#{}, RequestB),
228 | false = maps:is_key(<<"refresh_token">>, AccessToken).
229 |
230 | %%
231 | %%
232 | access_token_code_confidential(_) ->
233 | RequestA = <<"response_type=code&access=joe@org&secret=secret&scope=rd%3Dapi%26wr%3Dddb">>,
234 | {ok, {uri, https, _} = Uri} = oauth2:signin(digest(), RequestA),
235 | Code = uri:q(<<"code">>, undefined, Uri),
236 | RequestB = <<"grant_type=authorization_code&code=", Code/binary>>,
237 | {ok, #{
238 | <<"token_type">> := <<"bearer">>
239 | , <<"expires_in">> := _
240 | , <<"access_token">> := _
241 | , <<"refresh_token">>:= _
242 | , <<"rd">> := <<"api">>
243 | , <<"wr">> := <<"ddb">>
244 | }} = oauth2:token(digest(), RequestB).
245 |
246 | %%
247 | %%
248 | access_token_password_public(_) ->
249 | Request = <<"grant_type=password&client_id=public@org&username=joe@org&password=secret&scope=rd%3Dapi%26wr%3Dddb">>,
250 | {ok, #{
251 | <<"token_type">> := <<"bearer">>
252 | , <<"expires_in">> := _
253 | , <<"access_token">> := _
254 | , <<"rd">> := <<"api">>
255 | , <<"wr">> := <<"ddb">>
256 | } = AccessToken } = oauth2:token(#{}, Request),
257 | false = maps:is_key(<<"refresh_token">>, AccessToken).
258 |
259 | %%
260 | %%
261 | access_token_password_confidential(_) ->
262 | Request = <<"grant_type=password&username=joe@org&password=secret&scope=rd%3Dapi%26wr%3Dddb">>,
263 | {ok, #{
264 | <<"token_type">> := <<"bearer">>
265 | , <<"expires_in">> := _
266 | , <<"access_token">> := _
267 | , <<"refresh_token">>:= _
268 | , <<"rd">> := <<"api">>
269 | , <<"wr">> := <<"ddb">>
270 | }} = oauth2:token(digest(), Request).
271 |
272 | %%
273 | %%
274 | access_token_public(_) ->
275 | Request = <<"grant_type=client_credentials&client_id=public@org">>,
276 | {error, invalid_request} = oauth2:token(#{}, Request).
277 |
278 | %%
279 | %%
280 | access_token_confidential(_) ->
281 | Request = <<"grant_type=client_credentials">>,
282 | {ok, #{
283 | <<"token_type">> := <<"bearer">>
284 | , <<"expires_in">> := _
285 | , <<"access_token">> := _
286 | } = AccessToken} = oauth2:token(digest(), Request),
287 | false = maps:is_key(<<"refresh_token">>, AccessToken).
288 |
289 | %%
290 | %%
291 | refresh_token_public(_) ->
292 | Request = <<"grant_type=refresh_token&refresh_token=xxx&client_id=public@org">>,
293 | {error, invalid_request} = oauth2:token(#{}, Request).
294 |
295 | %%
296 | %%
297 | refresh_token_confidential(_) ->
298 | RequestA = <<"response_type=code&access=joe@org&secret=secret&scope=rd%3Dapi%26wr%3Dddb">>,
299 | {ok, {uri, https, _} = Uri} = oauth2:signin(digest(), RequestA),
300 | Code = uri:q(<<"code">>, undefined, Uri),
301 | RequestB = <<"grant_type=authorization_code&code=", Code/binary>>,
302 | {ok, #{
303 | <<"token_type">> := <<"bearer">>
304 | , <<"refresh_token">>:= Token
305 | }} = oauth2:token(digest(), RequestB),
306 | RequestC = <<"grant_type=refresh_token&refresh_token=", Token/binary>>,
307 | {ok, #{
308 | <<"token_type">> := <<"bearer">>
309 | , <<"expires_in">> := _
310 | , <<"access_token">> := _
311 | , <<"refresh_token">>:= _
312 | , <<"rd">> := <<"api">>
313 | , <<"wr">> := <<"ddb">>
314 | }} = oauth2:token(digest(), RequestC).
315 |
--------------------------------------------------------------------------------
/libs/oauth2/test/oauth2_client_SUITE.erl:
--------------------------------------------------------------------------------
1 | -module(oauth2_client_SUITE).
2 | -include_lib("common_test/include/ct.hrl").
3 | -include_lib("oauth2/include/oauth2.hrl").
4 |
5 | -export([all/0]).
6 | -compile(export_all).
7 |
8 | all() ->
9 | [Test || {Test, NAry} <- ?MODULE:module_info(exports),
10 | Test =/= module_info,
11 | Test =/= init_per_suite,
12 | Test =/= end_per_suite,
13 | NAry =:= 1
14 | ].
15 |
16 | %%%----------------------------------------------------------------------------
17 | %%%
18 | %%% init
19 | %%%
20 | %%%----------------------------------------------------------------------------
21 | init_per_suite(Config) ->
22 | os:putenv("PERMIT_ISSUER", "https://example.com"),
23 | os:putenv("PERMIT_AUDIENCE", "suite"),
24 | os:putenv("PERMIT_CLAIMS", "read=true&write=true"),
25 | {ok, _} = application:ensure_all_started(permit),
26 | erlang:apply(permit, create, oauth2_FIXTURES:client_public()),
27 | erlang:apply(permit, create, oauth2_FIXTURES:client_confidential()),
28 | Config.
29 |
30 |
31 | end_per_suite(_Config) ->
32 | application:stop(permit),
33 | ok.
34 |
35 | %%%----------------------------------------------------------------------------
36 | %%%
37 | %%% unit tests
38 | %%%
39 | %%%----------------------------------------------------------------------------
40 |
41 | %%
42 | auth_client_public(_) ->
43 | [_, _, Expect] = oauth2_FIXTURES:client_public(),
44 | {ok, Expect} = oauth2_client:public(<<"public@org">>),
45 | {error, forbidden} = oauth2_client:public(<<"confidential@org">>),
46 | {error, not_found} = oauth2_client:public(<<"undefined@org">>).
47 |
48 | %%
49 | auth_client_default(_) ->
50 | [_, _, Expect] = oauth2_FIXTURES:client_default(),
51 | {ok, Expect} = oauth2_client:public(<<"account@oauth2">>),
52 | {ok, Expect} = oauth2_client:public(<<"account@oauth2">>).
53 |
54 | %%
55 | auth_client_confidential(_) ->
56 | [_, _, Expect] = oauth2_FIXTURES:client_confidential(),
57 | {ok, Spec} = oauth2_client:confidential(digest(<<"confidential@org">>, <<"secret">>)),
58 | Expect = maps:without([<<"client_id">>, <<"client_jwt">>], Spec),
59 | {error,unauthorized} = oauth2_client:confidential(digest(<<"confidential@org">>, <<"undefined">>)),
60 | {error, forbidden} = oauth2_client:confidential(digest(<<"public@org">>, <<"secret">>)),
61 | {error, not_found} = oauth2_client:confidential(digest(<<"undefined@org">>, <<"secret">>)).
62 |
63 | digest(Access, Secret) ->
64 | <<"Basic ", (base64:encode(<>))/binary>>.
65 |
--------------------------------------------------------------------------------
/libs/oauth2/test/oauth2_codec_SUITE.erl:
--------------------------------------------------------------------------------
1 | -module(oauth2_codec_SUITE).
2 |
3 | -include_lib("common_test/include/ct.hrl").
4 | -include_lib("oauth2/include/oauth2.hrl").
5 | -compile({parse_transform, generic}).
6 |
7 | -export([all/0]).
8 | -compile(export_all).
9 |
10 | all() ->
11 | [Test || {Test, NAry} <- ?MODULE:module_info(exports),
12 | Test =/= module_info,
13 | Test =/= init_per_suite,
14 | Test =/= end_per_suite,
15 | NAry =:= 1
16 | ].
17 |
18 | %%%----------------------------------------------------------------------------
19 | %%%
20 | %%% unit tests
21 | %%%
22 | %%%----------------------------------------------------------------------------
23 |
24 | authorization(_) ->
25 | Request = <<"response_type=code&client_id=public@org&access=access@org&secret=secret&scope=read%3Dtrue%26write%3Dtrue">>,
26 | Generic = oauth2_codec:decode(Request),
27 | #authorization{
28 | response_type = <<"code">>
29 | , client_id = {iri, <<"mz_riE1VVY7WvBJLdbDygw">>, <<"public@org">>}
30 | , access = {iri, <<"dCZsHW4qX7ocGpZyZy8hMw">>, <<"access@org">>}
31 | , secret = <<"secret">>
32 | , scope = #{<<"read">> := <<"true">>, <<"write">> := <<"true">>}
33 | } = lens:get(oauth2_codec:authorization(), Generic).
34 |
35 | access_token(_) ->
36 | Request = <<"grant_type=authorization_code&client_id=public@org&code=xxx">>,
37 | Generic = oauth2_codec:decode(Request),
38 | #access_token{
39 | grant_type = <<"authorization_code">>
40 | , client_id = {iri, <<"org">>, <<"public">>}
41 | , code = <<"xxx">>
42 | }.
43 |
--------------------------------------------------------------------------------
/libs/oauth2/test/tests.config:
--------------------------------------------------------------------------------
1 | %%
2 | %% suites
3 | {suites, ".", all}.
4 |
--------------------------------------------------------------------------------
/serverless.mk:
--------------------------------------------------------------------------------
1 | ##
2 | ## Copyright (C) 2018 Dmitry Kolesnikov
3 | ##
4 | ## This Makefile may be modified and distributed under the terms
5 | ## of the MIT license. See the LICENSE file for details.
6 | ## https://github.com/fogfish/serverless
7 | ##
8 | ## @doc
9 | ## This makefile is the wrapper of rebar to build serverless applications
10 | ##
11 | ## @version 0.6.0
12 | .PHONY: all compile test dist distclean dist-up dist-rm
13 |
14 | APP := $(strip $(APP))
15 | VSN ?= $(shell test -z "`git status --porcelain 2> /dev/null`" && git describe --tags --long 2> /dev/null | sed -e 's/-g[0-9a-f]*//' | sed -e 's/-0//' || echo "`git describe --abbrev=0 --tags 2> /dev/null`-a")
16 | TEST ?= tests
17 | REBAR ?= 3.9.1
18 | DOCKER = fogfish/erlang-serverless:22.1
19 |
20 | ## erlang runtime configration flags
21 | ROOT = $(shell pwd)
22 | ADDR = localhost.localdomain
23 | EFLAGS = \
24 | -name ${APP}@${ADDR} \
25 | -setcookie ${COOKIE} \
26 | -pa ${ROOT}/_build/default/lib/*/ebin \
27 | -pa ${ROOT}/_build/default/lib/*/priv \
28 | -pa ${ROOT}/rel \
29 | -kernel inet_dist_listen_min 32100 \
30 | -kernel inet_dist_listen_max 32199 \
31 | +P 1000000 \
32 | +K true +A 160 -sbt ts
33 |
34 | #####################################################################
35 | ##
36 | ## build
37 | ##
38 | #####################################################################
39 | all: rebar3 test
40 |
41 | compile: rebar3
42 | @./rebar3 compile
43 |
44 | deps: rebar3
45 | @./rebar3 get-deps
46 |
47 | run: _build/default/bin/${APP}
48 | @test -z ${JSON} \
49 | && $^ -f ${EVENT} \
50 | || { T=`mktemp /tmp/lambda.XXXXXXX` ; trap "{ rm -f $$T; }" EXIT ;\
51 | jq -n --arg json "`cat ${JSON}`" -f ${EVENT} > $$T | $^ -f $$T; }
52 |
53 | shell:
54 | @erl ${EFLAGS}
55 |
56 | ##
57 | ## execute common test and terminate node
58 | test:
59 | @./rebar3 ct --config test/${TEST}.config --cover --verbose
60 | @./rebar3 cover
61 |
62 | ##
63 | ## clean
64 | clean:
65 | -@./rebar3 clean
66 | -@rm -rf _build/builder
67 | -@rm -rf _build/layer
68 | -@rm -rf _build/*.zip
69 | -@rm -rf log
70 | -@rm -rf _build/default/bin
71 |
72 | ##
73 | ##
74 | dist: _build/default/bin/${APP} _build/default/bin/bootstrap
75 |
76 | _build/default/bin/bootstrap:
77 | @printf "#!/bin/sh\nexport HOME=/opt\n/opt/serverless/bin/escript ${APP}\n" > $@ ;\
78 | chmod ugo+x $@
79 |
80 | _build/default/bin/${APP}: src/*.erl src/*.app.src
81 | @./rebar3 escriptize
82 |
83 | ##
84 | ##
85 | distclean: clean
86 | -@rm -Rf _build
87 | -@rm rebar3
88 | -@rm -Rf cloud/node_modules
89 |
90 |
91 | function:
92 | @I=`docker create ${DOCKER}` ;\
93 | docker cp $$I:/function/cloud . ;\
94 | docker cp $$I:/function/src . ;\
95 | docker cp $$I:/function/test . ;\
96 | docker cp $$I:/function/rebar.config . ;\
97 | docker rm -f $$I ;\
98 | sed -i '' -e "s/APP/${APP}/" cloud/* ;\
99 | sed -i '' -e "s/APP/${APP}/" src/* ;\
100 | sed -i '' -e "s/APP/${APP}/" test/* ;\
101 | sed -i '' -e "s/APP/${APP}/g" rebar.config ;\
102 | mv src/app.app.src src/${APP}.app.src ;\
103 | mv src/app.erl src/${APP}.erl ;\
104 | mv test/app_SUITE.erl test/${APP}_SUITE.erl ;\
105 | mkdir -p test ;\
106 | echo "{}" > ${EVENT}
107 |
108 |
109 | #####################################################################
110 | ##
111 | ## deploy
112 | ##
113 | #####################################################################
114 |
115 | dist-up: dist cloud/node_modules
116 | cd cloud && cdk deploy
117 |
118 | dist-rm: cloud/node_modules
119 | cd cloud && cdk destroy -f
120 |
121 | cloud/node_modules:
122 | cd cloud && npm install
123 |
124 | layer: _build/erlang-serverless.zip
125 | @aws lambda publish-layer-version \
126 | --layer-name erlang-serverless \
127 | --description "${DOCKER}" \
128 | --zip-file fileb://./$^
129 |
130 | _build/erlang-serverless.zip:
131 | @mkdir -p _build ;\
132 | echo "FROM ${DOCKER}\nRUN cd /opt && zip erlang-serverless.zip -r * > /dev/null" > _build/layer ;\
133 | docker build --force-rm=true --tag=build/erlang-serverless:latest - < _build/layer ;\
134 | I=`docker create build/erlang-serverless:latest` ;\
135 | docker cp $$I:/opt/erlang-serverless.zip $@ ;\
136 | docker rm -f $$I ;\
137 | docker rmi build/erlang-serverless:latest ;\
138 | test -f $@ && echo "==> $@"
139 |
140 |
141 | #####################################################################
142 | ##
143 | ## dependencies
144 | ##
145 | #####################################################################
146 | rebar3:
147 | @echo "==> install rebar (${REBAR})" ;\
148 | curl -L -O -s https://github.com/erlang/rebar3/releases/download/${REBAR}/rebar3 ;\
149 | chmod +x $@
150 |
--------------------------------------------------------------------------------