Merge branch 'ansible-deployment'

* ansible-deployment: (33 commits)
  Complete Vagrant deployment
  Add Go installation to Makefile
  Remove unused oidc_client_registration role
  Update Hydra setup
  Update test scenario description
  Update submodule commit references
  Reformatted
  Updated README to show current practice.
  Updated Makefile to include cacert_resources
  updated configuration and README
  Change default hostname for Hydra
  Update submodules
  Update to Debian 12 Bookworm
  Update cacert_resources ref commit
  Corrected typo in last line of README.md
  Updated ansible-playbook command to accept a password at the command prompt.
  Update to latest cacert_resources
  Update oidc_idp reference commit
  Update to latest oidc_idp
  Remove double "setup"
  ...
This commit is contained in:
Jan Dittberner 2023-08-08 15:44:17 +02:00
commit fce36b0d5e
50 changed files with 1404 additions and 7 deletions

4
.gitignore vendored
View file

@ -1 +1,5 @@
/.idea/ /.idea/
/.vagrant/
/deployment/*-from-vagrant.*
/mkcert_ca/
/tmp/

39
Makefile Normal file
View file

@ -0,0 +1,39 @@
SUBDIRS = cacert_resources oidc_app oidc_idp oidc_registration
GO_VERSION = 1.20.7
export PATH:=$(CURDIR)/tmp/go/bin:$(PATH)
all: cacert_resources oidc_app/demo-app oidc_idp/cacert-idp
$(CURDIR)/tmp/go$(GO_VERSION).linux-amd64.tar.gz:
mkdir -p tmp ; cd tmp ; \
curl -L -O https://go.dev/dl/go$(GO_VERSION).linux-amd64.tar.gz ; \
install_go: $(CURDIR)/tmp/go$(GO_VERSION).linux-amd64.tar.gz
tar x -C $(CURDIR)/tmp -f $(CURDIR)/tmp/go$(GO_VERSION).linux-amd64.tar.gz
go version
go env
install_yarn:
sudo apt install yarnpkg
cacert_resources: install_yarn force_look
echo building UI resources : $(MAKE) $(MFLAGS)
cd cacert_resources ; $(MAKE) $(MFLAGS)
oidc_app/demo-app: cacert_resources install_go force_look
echo building demo app : $(MAKE) $(MFLAGS)
cd oidc_app ; $(MAKE) $(MFLAGS)
oidc_idp/cacert-idp: cacert_resources install_go force_look
echo building CAcert IDP : $(MAKE) $(MFLAGS)
cd oidc_idp ; $(MAKE) $(MFLAGS)
clean:
echo cleaning up in .
-for d in $(SUBDIRS) ; do ( cd $$d; $(MAKE) clean ); done
force_look:
true
.PHONY: all clean install_go install_yarn

14
README-extra.md Normal file
View file

@ -0,0 +1,14 @@
### Extra PostgreSQL Notes
PostgreSQL should have been installed automatically as part of the installation of Debian 12.
see /usr/share/doc/postgresql-common for some documentation
If, for some reason, that installation is incomplete, it is best to re-install PostgreSQL in your Debian 12.
```shell
sudo apt update
sudo apt install postgresql postgresql-contrib
```

166
README.md
View file

@ -14,8 +14,168 @@ git config submodule.recurse true
## Get started ## Get started
- [setup Hydra](https://code.cacert.org/cacert/oidc-hydra-config/src/branch/main/README.md) Make sure you have the necessary prerequisites installed (tested on Debian 12
- build CAcert web application resources Bookworm) and `~/.local/bin` in your `$PATH` variable:
```shell
sudo apt update
sudo apt install git golang-go make mkcert postgresql python3-pip python3-venv yarnpkg
mkdir -p $HOME/.local/share/virtualenvs ~/.local/bin
python3 -m venv $HOME/.local/share/virtualenvs/ansible
$HOME/.local/share/virtualenvs/ansible/bin/pip install ansible
ln -s $HOME/.local/share/virtualenvs/ansible/bin/ansible* $HOME/.local/bin/
export PATH=$HOME/.local/bin:$PATH
```
*Note:* It is a good idea to put the `PATH` export line into your `.bashrc` or
`.zshenv`.
### Initial Configuration
*Note:* If you want to do everything manually, read on. Otherwise skip to the
ansible or Vagrant options below.
Each of the sub-directories contains instructions for creating or editing a
configuration file and, usually, certificates.
The first that must be performed are the instructions found in the
`hydra_config` sub-directory.
In that one, you must first install Hydra before you continue.
Next, create a certificate and key pair using mkcert, set your database
password, and generate a secret key for Hydra.
Following that, you need to create the Hydra configuration file, hydra.yaml.
Finally, after starting Hydra, you need to create a Hydra Client, using the
command found at the bottom of the README.md in that directory. Save the
values returned from that command.
Next, go in to the `cacert_resources` sub-directory and follow the directions
in that README.md regarding installing nodejs and webpack.
Third, go in to the `oidc_app` sub-directory.
There, you again need to create a certicate and key pair using mkcert.
Create the configuration file, resource_app.toml, using the values created
from the Hydra command described in the hydra_config README.md, and the two
secret keys as described in the current README.md file.
Next, the `oidc_idp` sub-directory.
Again, you will need to create the certificate and key pair using mkcert.
Create the configuration file, idp.toml, using only the a secret key, as
described in the current README.md file.
Finally, change into the `oidc_registration` sub-directory.
There, you will find detailed instructions for certificate creation for
this module.
As well, after creating a secret key, you will create the configuration
file, registration.toml.
### Continuing
At this point, you should have created all of the certificates and configuration files
needed by this system.
### Build the applications
Use `make` to build the web app resources and applications:
### Install the language translation tool
```shell
go install github.com/nicksnyder/go-i18n/v2/goi18n@latest
```
### Build the applications
Use `make` to build the web app resources and applications:
```shell
make
```
## Deployment options
There are two deployment options for the Hydra server and for the custom applications:
1. local deployment
2. Vagrant deployment
You only need one of these options.
Both options use [ansible](https://docs.ansible.com/) to:
- setup the Hydra authorization server
- setup IDP (provides login and consent screens) - setup IDP (provides login and consent screens)
- setup demo application - setup demo application
- setup setup OpenID Connect client registration application - setup OpenID Connect client registration application
### Local deployment
Use `ansible-playbook` to deploy Hydra, IDP, Client registration and the demo
application:
```shell
cd deployment
ansible-playbook 01_install_cacert_oidc.yml
```
*Note:* If ansible-playbook fails early in the process with "sudo: a password
is required," then confirm that your user has sudo privileges and execute the
`ansible-playbook` command like:
```shell
ansible-playbook -K 01_install_cacert_oidc.yml
```
### Vagrant setup
Instead of Ansible, you can also use [Vagrant](https://www.vagrantup.com/) with
the libvirt-provider. The included Vagrantfile is configured to apply the
ansible-playbook to the Vagrant managed virtual machine.
```shell
sudo apt install vagrant-libvirt virt-manager libvirt-clients
vagrant up
CAROOT=$(pwd)/mkcert_ca mkcert -install
```
The last step installs the `mkcert` CA certificate in your user's browser trust
store.
## Testing your local setup
After running `make` and `ansible-playbook`, Hydra and oidc-idp will both be running.
To run the rest of the components, in each of two new terminal windows, execute
`oidc_app/demo-app` and `oidc_registration/cacert-oidc-registration`.
### Test the authorization server
Request the OpenID connect auto discovery information from Hydra
```shell
curl https://hydra.cacert.localhost:4444/.well-known/openid-configuration | python3 -m json.tool
```
This should give you a JSON document with information about the authorization server.
### Test the identity provider
Open
[https://login.cacert.localhost:3000/](https://login.cacert.localhost:3000/)
this should ask you for a CAcert class 3 client certificate and should render a
welcome page with a CAcert logo.
### Test the demo application
Open [https://app.cacert.localhost:4000/](https://app.cacert.localhost:4000/)
to visit the demo application. Login should redirect you to the IDP, request
consent and redirect back to the application.

34
Vagrantfile vendored Normal file
View file

@ -0,0 +1,34 @@
# -*- mode: ruby -*-
# vi: set ft=ruby :
Vagrant.configure("2") do |config|
config.vm.box = "debian/bookworm64"
config.vm.define "oidcbox"
config.vm.network "forwarded_port", guest: 4444, host: 4444, host_ip: "127.0.0.1"
config.vm.network "forwarded_port", guest: 3000, host: 3000, host_ip: "127.0.0.1"
config.vm.network "forwarded_port", guest: 4000, host: 4000, host_ip: "127.0.0.1"
config.vm.provider "libvirt" do |lv|
lv.memory = "2048"
lv.cpus = 2
lv.machine_virtual_size = 10
lv.memorybacking :access, :mode => "shared"
end
config.vm.synced_folder "./", "/vagrant", type: "virtiofs"
config.vm.provision "ansible" do |ansible|
ansible.playbook = "deployment/01_install_cacert_oidc.yml"
ansible.verbose = true
ansible.groups = {
"pgsqlserver" => ["oidcbox"],
"authserver" => ["oidcbox"],
"demoserver" => ["oidcbox"]
}
ansible.extra_vars = {
mkcert_caroot: "/vagrant/mkcert_ca"
}
end
end

@ -1 +1 @@
Subproject commit 5cbcbefac6f05fa6537b6a925cc29118a5ecc571 Subproject commit e6be3d2cf94db1be5fcab35db94a84b94f218634

View file

@ -0,0 +1,43 @@
---
- name: Install development tools
hosts: all
become: false
roles:
- prepare_devtools
- name: Setup database
hosts: pgsqlserver
become: true
pre_tasks:
- name: Install package python3-psycopg2
ansible.builtin.apt:
name: python3-psycopg2
state: present
# The ACL package is required to run commands as the postgres user
# See https://docs.ansible.com/ansible-core/2.12/user_guide/become.html#risks-of-becoming-an-unprivileged-user
- name: Install package acl
ansible.builtin.apt:
name: acl
state: present
roles:
- hydra_database
- name: Install authorization server
hosts: authserver
become: true
roles:
- hydra_server
- oidc_idp
- name: Install demo application
hosts: demoserver
become: true
roles:
- oidc_demo_application

4
deployment/README.md Normal file
View file

@ -0,0 +1,4 @@
Deployment automation for the CAcert OIDC setup
This directory contains [Ansible](https://docs.ansible.com) automation code to
install and setup the CAcert OpenID connect components.

35
deployment/ansible.cfg Normal file
View file

@ -0,0 +1,35 @@
[defaults]
# (boolean) By default Ansible will issue a warning when received from a task action (module or action plugin)
# These warnings can be silenced by adjusting this setting to False.
action_warnings=True
# (string) Chooses which cache plugin to use, the default 'memory' is ephemeral.
fact_caching=memory
# (pathlist) Comma separated list of Ansible inventory sources
inventory=inventory/local
# (pathspec) Colon separated paths in which Ansible will search for Roles.
roles_path=./roles:~/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles
# (boolean) Toggles the use of persistence for connections.
use_persistent_connections=True
interpreter_python=auto_silent
[privilege_escalation]
# (boolean) Display an agnostic become prompt instead of displaying a prompt containing the command line supplied become method
agnostic_become_prompt=True
# (boolean) This setting controls if become is skipped when remote user and become user are the same. I.E root sudo to root.
# If executable, it will be run and the resulting stdout will be used as the password.
become_allow_same_user=False
[diff]
# (bool) Configuration toggle to tell modules to show differences when in 'changed' status, equivalent to ``--diff``.
always=True
# (integer) How many lines of context to show when displaying the differences between files.
context=3

View file

@ -0,0 +1,16 @@
---
hydra_home: /srv/hydra
oidc_urls:
hydra_admin:
host: hydra.cacert.localhost
port: 4445
hydra_public:
host: auth.cacert.localhost
port: 4444
idp:
host: login.cacert.localhost
port: 3000
demoapp:
host: app.cacert.localhost
port: 4000

View file

@ -0,0 +1,40 @@
---
# defaults to CAcert class 3 certificate
idp:
client_certificate_data: |
-----BEGIN CERTIFICATE-----
MIIGPTCCBCWgAwIBAgIDFOIoMA0GCSqGSIb3DQEBDQUAMHkxEDAOBgNVBAoTB1Jv
b3QgQ0ExHjAcBgNVBAsTFWh0dHA6Ly93d3cuY2FjZXJ0Lm9yZzEiMCAGA1UEAxMZ
Q0EgQ2VydCBTaWduaW5nIEF1dGhvcml0eTEhMB8GCSqGSIb3DQEJARYSc3VwcG9y
dEBjYWNlcnQub3JnMB4XDTIxMDQxOTEyMTgzMFoXDTMxMDQxNzEyMTgzMFowVDEU
MBIGA1UEChMLQ0FjZXJ0IEluYy4xHjAcBgNVBAsTFWh0dHA6Ly93d3cuQ0FjZXJ0
Lm9yZzEcMBoGA1UEAxMTQ0FjZXJ0IENsYXNzIDMgUm9vdDCCAiIwDQYJKoZIhvcN
AQEBBQADggIPADCCAgoCggIBAKtJNRFIfNImflOUz0Op3SjXQiqL84d4GVh8D57a
iX3h++tykA10oZZkq5+gJJlz2uJVdscXe/UErEa4w75/ZI0QbCTzYZzA8pD6Ueb1
aQFjww9W4kpCz+JEjCUoqMV5CX1GuYrz6fM0KQhF5Byfy5QEHIGoFLOYZcRD7E6C
jQnRvapbjZLQ7N6QxX8KwuPr5jFaXnQ+lzNZ6MMDPWAzv/fRb0fEze5ig1JuLgia
pNkVGJGmhZJHsK5I6223IeyFGmhyNav/8BBdwPSUp2rVO5J+TJAFfpPBLIukjmJ0
FXFuC3ED6q8VOJrU0gVyb4z5K+taciX5OUbjchs+BMNkJyIQKopPWKcDrb60LhPt
XapI19V91Cp7XPpGBFDkzA5CW4zt2/LP/JaT4NsRNlRiNDiPDGCbO5dWOK3z0luL
oFvqTpa4fNfVoIZwQNORKbeiPK31jLvPGpKK5DR7wNhsX+kKwsOnIJpa3yxdUly6
R9Wb7yQocDggL9V/KcCyQQNokszgnMyXS0XvOhAKq3A6mJVwrTWx6oUrpByAITGp
rmB6gCZIALgBwJNjVSKRPFbnr9s6JfOPMVTqJouBWfmh0VMRxXudA/Z0EeBtsSw/
LIaRmXGapneLNGDRFLQsrJ2vjBDTn8Rq+G8T/HNZ92ZCdB6K4/jc0m+YnMtHmJVA
BfvpAgMBAAGjgfIwge8wDwYDVR0TAQH/BAUwAwEB/zBhBggrBgEFBQcBAQRVMFMw
IwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3NwLkNBY2VydC5vcmcvMCwGCCsGAQUFBzAC
hiBodHRwOi8vd3d3LkNBY2VydC5vcmcvY2xhc3MzLmNydDBFBgNVHSAEPjA8MDoG
CysGAQQBgZBKAgMBMCswKQYIKwYBBQUHAgEWHWh0dHA6Ly93d3cuQ0FjZXJ0Lm9y
Zy9jcHMucGhwMDIGA1UdHwQrMCkwJ6AloCOGIWh0dHBzOi8vd3d3LmNhY2VydC5v
cmcvY2xhc3MzLmNybDANBgkqhkiG9w0BAQ0FAAOCAgEAxh6td1y0KJvRyI1EEsC9
dnYEgyEH+BGCf2vBlULAOBG1JXCNiwzB1Wz9HBoDfIv4BjGlnd5BKdSLm4TXPcE3
hnGjH1thKR5dd3278K25FRkTFOY1gP+mGbQ3hZRB6IjDX+CyBqS7+ECpHTms7eo/
mARN+Yz5R3lzUvXs3zSX+z534NzRg4i6iHNHWqakFcQNcA0PnksTB37vGD75pQGq
eSmx51L6UzrIpn+274mhsaFNL85jhX+lKuk71MGjzwoThbuZ15xmkITnZtRQs6Hh
LSIqJWjDILIrxLqYHehK71xYwrRNhFb3TrsWaEJskrhveM0Os/vvoLNkh/L3iEQ5
/LnmLMCYJNRALF7I7gsduAJNJrgKGMYvHkt1bo8uIXO8wgNV7qoU4JoaB1ML30QU
qGcFr0TI06FFdgK2fwy5hulPxm6wuxW0v+iAtXYx/mRkwQpYbcVQtrIDvx1CT1k5
0cQxi+jIKjkcFWHw3kBoDnCos0/ukegPT7aQnk2AbL4c7nCkuAcEKw1BAlSETkfq
i5btdlhh58MhewZv1LcL5zQyg8w1puclT3wXQvy8VwPGn0J/mGD4gLLZ9rGcHDUE
CokxFoWk+u5MCcVqmGbsyG4q5suS3CNslsHURfM8bQK4oLvHR8LCHEBMRcdFBn87
cSvOK6eB1kdGKLA8ymXxZp8=
-----END CERTIFICATE-----

View file

@ -0,0 +1,4 @@
---
demoapp_tls:
cert: "{{ cacert_home }}/etc/app.cacert.localhost.pem"
key: "{{ cacert_home }}/etc/app.cacert.localhost-key.pem"

View file

@ -0,0 +1,35 @@
---
# this is for a localhost deployment, database passwords for public servers
# must be different random values encrypted via ansible-vault
hydra_db_password: hydra
hydra_db_host: localhost
hydra_db_port: 5432
hydra_tls:
cert: "{{ hydra_home }}/etc/localhost+2.pem"
key: "{{ hydra_home }}/etc/localhost+2-key.pem"
# this is for a localhost deployment, secrets for public servers must be
# different random values encrypted via ansible-vault
hydra_system_secret: "AczA+NZ25Ye9eAreglv5bo9XcND6uwBQHVUYCvPfwXo="
demoapp_tls:
cert: "{{ cacert_home }}/etc/app.cacert.localhost.pem"
key: "{{ cacert_home }}/etc/app.cacert.localhost-key.pem"
idp_tls:
cert: "{{ cacert_home }}/etc/idp.cacert.localhost.pem"
key: "{{ cacert_home }}/etc/idp.cacert.localhost-key.pem"
oidc_urls:
hydra_admin:
host: hydra.cacert.localhost
port: 4445
hydra_public:
address: localhost
host: auth.cacert.localhost
port: 4444
idp:
host: login.cacert.localhost
port: 3000
demoapp:
host: app.cacert.localhost
port: 4000

View file

@ -0,0 +1,20 @@
---
# this is for a localhost deployment, database passwords for public servers
# must be different random values encrypted via ansible-vault
hydra_db_password: hydra
hydra_db_host: localhost
hydra_db_port: 5432
hydra_tls:
cert: "{{ hydra_home }}/etc/localhost+2.pem"
key: "{{ hydra_home }}/etc/localhost+2-key.pem"
# this is for a localhost deployment, secrets for public servers must be
# different random values encrypted via ansible-vault
hydra_system_secret: "AczA+NZ25Ye9eAreglv5bo9XcND6uwBQHVUYCvPfwXo="
idp_tls:
cert: "{{ cacert_home }}/etc/idp.cacert.localhost.pem"
key: "{{ cacert_home }}/etc/idp.cacert.localhost-key.pem"
demoapp_tls:
cert: "{{ cacert_home }}/etc/app.cacert.localhost.pem"
key: "{{ cacert_home }}/etc/app.cacert.localhost-key.pem"

View file

@ -0,0 +1,10 @@
localhost ansible_connection=local
[pgsqlserver]
localhost
[authserver]
localhost
[demoserver]
localhost

View file

@ -0,0 +1,38 @@
Hydra Database
==============
Setup a PostgreSQL database for [ORY Hydra](https://ory.sh/hydra/).
Requirements
------------
The role expects a Debian system running Debian 11 or later.
Role Variables
--------------
| Name | Description | Default |
| ------------------- | ----------------- | ------- |
| `hydra_db_name` | Database name | `hydra` |
| `hydra_db_user` | Database user | `hydra` |
| `hydra_db_password` | Database password | - |
Example Playbook
----------------
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
- hosts: servers
roles:
- hydra_database
License
-------
GPL-2.0-or-later
Author Information
------------------
Jan Dittberner <jandd@cacert.org>

View file

@ -0,0 +1,3 @@
---
hydra_db_name: hydra
hydra_db_user: hydra

View file

@ -0,0 +1,2 @@
---
# handlers file for hydra_database

View file

@ -0,0 +1,17 @@
---
galaxy_info:
author: Jan Dittberner
description: ORY Hydra database setup
company: CAcert
issue_tracker_url: https://code.cacert.org/cacert/oidc-parent/issues
license: GPL-2.0-or-later
min_ansible_version: 2.1
platforms:
- name: Debian
versions:
- bullseye
- bookworm
galaxy_tags: []
dependencies: []

View file

@ -0,0 +1,37 @@
---
- name: Install PostgreSQL server
ansible.builtin.package:
name: postgresql
state: present
- name: Create Hydra database
community.postgresql.postgresql_db:
name: "{{ hydra_db_name }}"
encoding: UTF-8
template: template0
state: present
become_user: postgres
- name: Create Hydra database user
community.postgresql.postgresql_user:
name: "{{ hydra_db_user }}"
password: "{{ hydra_db_password }}"
state: present
become_user: postgres
- name: Grant permissions on Hydra database to Hydra database user
community.postgresql.postgresql_privs:
database: "{{ hydra_db_name }}"
privs: CONNECT
type: database
role: "{{ hydra_db_user }}"
become_user: postgres
- name: Grant permissions on public schema of Hydra database to Hydra database user
community.postgresql.postgresql_privs:
database: "{{ hydra_db_name }}"
objs: public
privs: CREATE,USAGE
type: schema
role: "{{ hydra_db_user }}"
become_user: postgres

View file

@ -0,0 +1,38 @@
Role Name
=========
A brief description of the role goes here.
Requirements
------------
Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
Role Variables
--------------
A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
Dependencies
------------
A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
Example Playbook
----------------
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
- hosts: servers
roles:
- { role: username.rolename, x: 42 }
License
-------
BSD
Author Information
------------------
An optional section for the role authors to include contact information, or a website (HTML is not allowed).

View file

@ -0,0 +1,11 @@
---
hydra_db_name: hydra
hydra_db_user: hydra
hydra_os_group: hydra
hydra_os_user: hydra
hydra_home: /srv/hydra
hydra_version: "2.1.2"
hydra_checksum: "acab44b1f5324e001fcfecaa7115a5c3a07156e3e0d3840d8ed12deca4db6490"
use_mkcert: true

View file

@ -0,0 +1,7 @@
---
- name: hydra_systemd_reload
ansible.builtin.systemd:
state: restarted
name: hydra
daemon_reload: true
enabled: true

View file

@ -0,0 +1,52 @@
galaxy_info:
author: Jan Dittberner
description: Setup ORY Hydra server
company: CAcert
# If the issue tracker for your role is not on github, uncomment the
# next line and provide a value
# issue_tracker_url: http://example.com/issue/tracker
# Choose a valid license ID from https://spdx.org - some suggested licenses:
# - BSD-3-Clause (default)
# - MIT
# - GPL-2.0-or-later
# - GPL-3.0-only
# - Apache-2.0
# - CC-BY-4.0
license: GPL-2.0-or-later
min_ansible_version: 2.1
# If this a Container Enabled role, provide the minimum Ansible Container version.
# min_ansible_container_version:
#
# Provide a list of supported platforms, and for each platform a list of versions.
# If you don't wish to enumerate all versions for a particular platform, use 'all'.
# To view available platforms and versions (or releases), visit:
# https://galaxy.ansible.com/api/v1/platforms/
#
# platforms:
# - name: Fedora
# versions:
# - all
# - 25
# - name: SomePlatform
# versions:
# - all
# - 1.0
# - 7
# - 99.99
galaxy_tags: []
# List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -0,0 +1,109 @@
---
- name: Create Hydra group
ansible.builtin.group:
name: "{{ hydra_os_group }}"
state: present
system: true
- name: Create Hydra user
ansible.builtin.user:
name: "{{ hydra_os_user }}"
group: "{{ hydra_os_group }}"
home: "{{ hydra_home }}"
state: present
system: true
- name: Create Hydra directories
ansible.builtin.file:
path: "{{hydra_home }}/{{ item.path }}"
owner: "{{ hydra_os_user }}"
group: "{{ hydra_os_group }}"
mode: "{{ item.mode }}"
state: directory
loop:
- { path: etc, mode: '0750' }
- { path: bin, mode: '0750' }
- { path: download, mode: '0750' }
- name: Download Hydra binary
ansible.builtin.get_url:
url: "https://github.com/ory/hydra/releases/download/v{{ hydra_version }}/hydra_{{ hydra_version }}-linux_64bit.tar.gz"
dest: "{{ hydra_home }}/download/hydra_{{ hydra_version }}-linux_64bit.tar.gz"
checksum: "sha256:{{ hydra_checksum }}"
owner: "{{ hydra_os_user }}"
group: "{{ hydra_os_group }}"
mode: '0640'
- name: Extract Hydra binary
ansible.builtin.unarchive:
remote_src: true
src: "{{ hydra_home }}/download/hydra_{{ hydra_version }}-linux_64bit.tar.gz"
dest: "{{ hydra_home }}/bin"
owner: root
group: "{{ hydra_os_group }}"
include: 'hydra'
mode: '0750'
- name: Create Hydra configuration
ansible.builtin.template:
src: hydra.yml.j2
dest: "{{ hydra_home }}/etc/hydra.yml"
owner: root
group: "{{ hydra_os_group }}"
mode: '0640'
notify: hydra_systemd_reload
- name: Check whether certificate exists
ansible.builtin.stat:
path: "{{ hydra_tls.cert }}"
register: hydra_cert_st
- name: Create Hydra key and certificate with mkcert
block:
- name: Create temporary directory for Hydra key and certificate
ansible.builtin.tempfile:
prefix: "hydra-cert."
state: directory
register: hydra_cert_temp_dir
- name: Create Hydra key and certificate
ansible.builtin.command:
cmd: "mkcert -cert-file {{ hydra_cert_temp_dir.path }}/hydra.pem -key-file {{ hydra_cert_temp_dir.path }}/hydra.key.pem {{ oidc_urls.hydra_admin.host }} {{ oidc_urls.hydra_public.host }}"
environment:
CAROOT: "{{ mkcert_caroot | default(omit) }}"
- name: Move Hydra certificate and key to target
ansible.builtin.copy:
src: "{{ hydra_cert_temp_dir.path }}/{{ item.src }}"
dest: "{{ item.dest }}"
owner: root
group: "{{ hydra_os_group }}"
mode: "{{ item.mode }}"
remote_src: true
loop:
- {src: hydra.pem, dest: "{{ hydra_tls.cert }}", mode: '0644'}
- {src: hydra.key.pem, dest: "{{ hydra_tls.key }}", mode: '0640'}
become: true
- name: Remove temporary directory
ansible.builtin.file:
path: "{{ hydra_cert_temp_dir.path }}"
state: absent
when: not hydra_cert_st.stat.exists
become: false
- name: Run Hydra SQL migrations
ansible.builtin.command:
cmd: "{{ hydra_home }}/bin/hydra migrate sql --yes --read-from-env --config {{ hydra_home }}/etc/hydra.yml"
changed_when: false
- name: Create systemd unit file
ansible.builtin.template:
src: hydra.service.j2
dest: /etc/systemd/system/hydra.service
owner: root
group: root
mode: "0640"
notify: hydra_systemd_reload

View file

@ -0,0 +1,13 @@
[Unit]
Description=ORY Hydra OAuth2/OpenID Connect API server
After=network.target
Documentation=https://www.ory.sh/docs/hydra/
[Service]
ExecStart={{ hydra_home }}/bin/hydra serve all --config "{{ hydra_home }}/etc/hydra.yml"
WorkingDirectory={{ hydra_home }}
User={{ hydra_os_user }}
Group={{ hydra_os_group }}
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,49 @@
---
serve:
admin:
host: {{ oidc_urls.hydra_admin.address | default("localhost") }}
port: {{ oidc_urls.hydra_admin.port | default("4445") }}
tls:
enabled: true
cert:
path: {{ hydra_tls.cert }}
key:
path: {{ hydra_tls.key }}
public:
host: {{ oidc_urls.hydra_public.address | default(ansible_default_ipv4.address) }}
port: {{ oidc_urls.hydra_public.port | default("4444") }}
tls:
enabled: true
cert:
path: {{ hydra_tls.cert }}
key:
path: {{ hydra_tls.key }}
dsn: 'postgres://{{ hydra_db_user }}:{{ hydra_db_password }}@{{ hydra_db_host }}:{{ hydra_db_port }}/{{ hydra_db_name }}'
webfinger:
oidc_discovery:
supported_claims:
- email
- email_verified
- name
supported_scope:
- profile
- email
oauth2:
expose_internal_errors: false
urls:
login: https://{{ oidc_urls.idp.host }}:{{ oidc_urls.idp.port }}/login
consent: https://{{ oidc_urls.idp.host }}:{{ oidc_urls.idp.port }}/consent
logout: https://{{ oidc_urls.idp.host }}:{{ oidc_urls.idp.port }}/logout
error: https://{{ oidc_urls.idp.host }}:{{ oidc_urls.idp.port }}/error
post_logout_redirect: https://{{ oidc_urls.idp.host }}:{{ oidc_urls.idp.port }}/logout-successful
self:
public: https://{{ oidc_urls.hydra_public.host }}:{{ oidc_urls.hydra_public.port }}/
issuer: https://{{ oidc_urls.hydra_public.host }}:{{ oidc_urls.hydra_public.port }}/
secrets:
system:
- "{{ hydra_system_secret }}"

View file

@ -0,0 +1,2 @@
---
# vars file for roles/hydra_server

View file

@ -0,0 +1,38 @@
Role Name
=========
A brief description of the role goes here.
Requirements
------------
Any pre-requisites that may not be covered by Ansible itself or the role should be mentioned here. For instance, if the role uses the EC2 module, it may be a good idea to mention in this section that the boto package is required.
Role Variables
--------------
A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
Dependencies
------------
A list of other roles hosted on Galaxy should go here, plus any details in regards to parameters that may need to be set for other roles, or variables that are used from other roles.
Example Playbook
----------------
Including an example of how to use your role (for instance, with variables passed in as parameters) is always nice for users too:
- hosts: servers
roles:
- { role: username.rolename, x: 42 }
License
-------
BSD
Author Information
------------------
An optional section for the role authors to include contact information, or a website (HTML is not allowed).

View file

@ -0,0 +1,4 @@
---
cacert_os_user: cacert
cacert_os_group: cacert
cacert_home: /srv/cacert

View file

@ -0,0 +1,7 @@
---
- name: demoapp_systemd_reload
ansible.builtin.systemd:
state: restarted
name: cacert-demoapp
daemon_reload: true
enabled: true

View file

@ -0,0 +1,52 @@
galaxy_info:
author: your name
description: your role description
company: your company (optional)
# If the issue tracker for your role is not on github, uncomment the
# next line and provide a value
# issue_tracker_url: http://example.com/issue/tracker
# Choose a valid license ID from https://spdx.org - some suggested licenses:
# - BSD-3-Clause (default)
# - MIT
# - GPL-2.0-or-later
# - GPL-3.0-only
# - Apache-2.0
# - CC-BY-4.0
license: license (GPL-2.0-or-later, MIT, etc)
min_ansible_version: 2.1
# If this a Container Enabled role, provide the minimum Ansible Container version.
# min_ansible_container_version:
#
# Provide a list of supported platforms, and for each platform a list of versions.
# If you don't wish to enumerate all versions for a particular platform, use 'all'.
# To view available platforms and versions (or releases), visit:
# https://galaxy.ansible.com/api/v1/platforms/
#
# platforms:
# - name: Fedora
# versions:
# - all
# - 25
# - name: SomePlatform
# versions:
# - all
# - 1.0
# - 7
# - 99.99
galaxy_tags: []
# List tags for your role here, one per line. A tag is a keyword that describes
# and categorizes the role. Users find roles by searching for tags. Be sure to
# remove the '[]' above, if you add tags to this list.
#
# NOTE: A tag is limited to a single word comprised of alphanumeric characters.
# Maximum 20 tags per role.
dependencies: []
# List your role dependencies here, one per line. Be sure to remove the '[]' above,
# if you add dependencies to this list.

View file

@ -0,0 +1,166 @@
---
- name: Manage /etc/hosts
blockinfile:
path: /etc/hosts
create: true
block: |
127.0.0.1 localhost
127.0.0.2 bookworm
::1 localhost ip6-localhost ip6-loopback
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
{{ oidc_urls.hydra_public.address | default(ansible_default_ipv4.address) }} {{ oidc_urls.hydra_public.host }}
127.0.0.1 {{ oidc_urls.demoapp.host }}
- name: Create CAcert group
ansible.builtin.group:
name: "{{ cacert_os_group }}"
state: present
system: true
- name: Create CAcert user
ansible.builtin.user:
name: "{{ cacert_os_user }}"
group: "{{ cacert_os_group }}"
home: "{{ cacert_home }}"
state: present
system: true
- name: Create CAcert directories
ansible.builtin.file:
path: "{{ cacert_home }}/{{ item.path }}"
owner: "{{ cacert_os_user }}"
group: "{{ cacert_os_group }}"
mode: "{{ item.mode }}"
state: directory
loop:
- { path: etc, mode: '0750' }
- { path: bin, mode: '0750' }
- { path: download, mode: '0750' }
- name: Create session directory
ansible.builtin.file:
path: "{{ demoapp_session_path | default('/var/cache/cacert/sessions') }}"
owner: "{{ cacert_os_user }}"
group: "{{ cacert_os_group }}"
mode: "0750"
state: directory
- name: Copy demo application binary
ansible.builtin.copy:
src: ../oidc_app/demo-app
dest: "{{ cacert_home }}/bin/cacert-oidcdemo"
owner: root
group: "{{ cacert_os_group }}"
mode: "0750"
- name: Check whether certificate exists
ansible.builtin.stat:
path: "{{ demoapp_tls.cert }}"
register: demoapp_cert_st
- name: Create demo application key and certificate with mkcert
block:
- name: Create temporary directory for demo application key and certificate
ansible.builtin.tempfile:
prefix: "demoapp-cert."
state: directory
register: demoapp_cert_temp_dir
- name: Create demo application key and certificate
ansible.builtin.command:
cmd: "mkcert -cert-file {{ demoapp_cert_temp_dir.path }}/demoapp.pem -key-file {{ demoapp_cert_temp_dir.path }}/demoapp.key.pem {{ oidc_urls.demoapp.host }}"
environment:
CAROOT: "{{ mkcert_caroot | default(omit) }}"
- name: Move demo application certificate and key to target
ansible.builtin.copy:
src: "{{ demoapp_cert_temp_dir.path }}/{{ item.src }}"
dest: "{{ item.dest }}"
owner: root
group: "{{ cacert_os_group }}"
mode: "{{ item.mode }}"
remote_src: true
loop:
- {src: demoapp.pem, dest: "{{ demoapp_tls.cert }}", mode: '0644'}
- {src: demoapp.key.pem, dest: "{{ demoapp_tls.key }}", mode: '0640'}
become: true
- name: Remove temporary directory
ansible.builtin.file:
path: "{{ demoapp_cert_temp_dir.path }}"
state: absent
when: not demoapp_cert_st.stat.exists
become: false
- name: Check whether configuration file exists
ansible.builtin.stat:
path: "{{ cacert_home }}/etc/cacert-demoapp.toml"
register: demoapp_config_st
- name: Get credentials from existing file
block:
- name: fetch existing configuration file
ansible.builtin.fetch:
src: "{{ demoapp_config_st.stat.path }}"
dest: demoapp_config-from-vagrant.toml
flat: true
- name: set credential facts
ansible.builtin.set_fact:
demoapp_client_id: "{{ lookup('ansible.builtin.ini', 'client-id', section='oidc', file='demoapp_config-from-vagrant.toml') | from_json }}"
demoapp_client_secret: "{{ lookup('ansible.builtin.ini', 'client-secret', section='oidc', file='demoapp_config-from-vagrant.toml') | from_json }}"
demoapp_auth_key: "{{ lookup('ansible.builtin.ini', 'auth-key', section='session', file='demoapp_config-from-vagrant.toml') | from_json }}"
demoapp_enc_key: "{{ lookup('ansible.builtin.ini', 'enc-key', section='session', file='demoapp_config-from-vagrant.toml') | from_json }}"
when: demoapp_config_st.stat.exists
- name: Generate new credentials
block:
- name: Create new client via Hydra admin API
ansible.builtin.uri:
url: "https://{{ oidc_urls.hydra_admin.host }}:{{ oidc_urls.hydra_admin.port }}/admin/clients"
method: "POST"
body:
client_name: "CAcert OIDC demo application"
redirect_uris:
- "https://{{ oidc_urls.demoapp.host }}:{{ oidc_urls.demoapp.port }}/callback"
post_logout_redirect_uris:
- "https://{{ oidc_urls.demoapp.host }}:{{ oidc_urls.demoapp.port }}/after-logout"
scope: "openid email profile groups"
body_format: "json"
headers:
Accept: "application/json"
Content-Type: "application/json"
status_code: [201]
register: hydra_response
- name: Set credential facts
ansible.builtin.set_fact:
demoapp_client_id: "{{ hydra_response.json.client_id }}"
demoapp_client_secret: "{{ hydra_response.json.client_secret }}"
when: not demoapp_config_st.stat.exists
- name: Create demo application configuration
ansible.builtin.template:
src: demoapp_config.toml.j2
dest: "{{ cacert_home }}/etc/cacert-demoapp.toml"
owner: root
group: "{{ cacert_os_group }}"
mode: '0640'
notify: demoapp_systemd_reload
- name: Create demoapp systemd unit file
ansible.builtin.template:
src: cacert-demoapp.service.j2
dest: /etc/systemd/system/cacert-demoapp.service
owner: root
group: root
mode: "0640"
notify: demoapp_systemd_reload

View file

@ -0,0 +1,14 @@
[Unit]
Description=CAcert OpenID Connect demo application
After=network.target
Documentation=https://code.cacert.org/cacert/oidc-demo-app
[Service]
ExecStart={{ cacert_home }}/bin/cacert-oidcdemo --conf "{{ cacert_home }}/etc/cacert-demoapp.toml"
WorkingDirectory={{ cacert_home }}
User={{ cacert_os_user }}
Group={{ cacert_os_group }}
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,19 @@
[oidc]
client-id = "{{ demoapp_client_id }}"
client-secret = "{{ demoapp_client_secret }}"
server = "https://{{ oidc_urls.hydra_public.host }}:{{ oidc_urls.hydra_public.port }}/"
[server]
name = "{{ oidc_urls.demoapp.host }}"
address = "{{ oidc_urls.demoapp.address | default(ansible_default_ipv4.address) }}"
port = {{ oidc_urls.demoapp.address | default("4000") }}
certificate = "{{ demoapp_tls.cert }}"
key = "{{ demoapp_tls.key }}"
[session]
auth-key = "{{ demoapp_auth_key | default(lookup('community.general.random_string', length=64, base64=true)) }}"
enc-key = "{{ demoapp_enc_key | default(lookup('community.general.random_string', length=32, base64=true)) }}"
path = "{{ demoapp_session_path | default('/var/cache/cacert/sessions') }}"
[log]
level = "trace"

View file

@ -0,0 +1,2 @@
---
# vars file for roles/oidc_demo_application

View file

@ -0,0 +1,19 @@
Role Name
=========
Setup the CAcert OpenID Connect identity provider application.
Role Variables
--------------
A description of the settable variables for this role should go here, including any variables that are in defaults/main.yml, vars/main.yml, and any variables that can/should be set via parameters to the role. Any variables that are read from other roles and/or the global scope (ie. hostvars, group vars, etc.) should be mentioned here as well.
License
-------
GPL-2.0-or-later
Author Information
------------------
Jan Dittberner <jandd@cacert.org>

View file

@ -0,0 +1,4 @@
---
cacert_os_user: cacert
cacert_os_group: cacert
cacert_home: /srv/cacert

View file

@ -0,0 +1,7 @@
---
- name: idp_systemd_reload
ansible.builtin.systemd:
state: restarted
name: cacert-idp
daemon_reload: true
enabled: true

View file

@ -0,0 +1,17 @@
---
galaxy_info:
author: Jan Dittberner
description: CAcert OpenID Connect identity provider application setup
company: CAcert
license: GPL-2.0-or-later
min_ansible_version: 2.1
platforms:
- name: Debian
versions:
- bullseye
- bookworm
galaxy_tags: []

View file

@ -0,0 +1,123 @@
---
- name: Create CAcert group
ansible.builtin.group:
name: "{{ cacert_os_group }}"
state: present
system: true
- name: Create CAcert user
ansible.builtin.user:
name: "{{ cacert_os_user }}"
group: "{{ cacert_os_group }}"
home: "{{ cacert_home }}"
state: present
system: true
- name: Create CAcert directories
ansible.builtin.file:
path: "{{ cacert_home }}/{{ item.path }}"
owner: "{{ cacert_os_user }}"
group: "{{ cacert_os_group }}"
mode: "{{ item.mode }}"
state: directory
loop:
- { path: etc, mode: '0750' }
- { path: bin, mode: '0750' }
- { path: download, mode: '0750' }
- name: Copy IDP binary
ansible.builtin.copy:
src: ../oidc_idp/cacert-idp
dest: "{{ cacert_home }}/bin/cacert-idp"
owner: root
group: "{{ cacert_os_group }}"
mode: "0750"
- name: Check whether certificate exists
ansible.builtin.stat:
path: "{{ idp_tls.cert }}"
register: idp_cert_st
- name: Create IDP key and certificate with mkcert
block:
- name: Create temporary directory for IDP key and certificate
ansible.builtin.tempfile:
prefix: "idp-cert."
state: directory
register: idp_cert_temp_dir
- name: Create IDP key and certificate
ansible.builtin.command:
cmd: "mkcert -cert-file {{ idp_cert_temp_dir.path }}/idp.pem -key-file {{ idp_cert_temp_dir.path }}/idp.key.pem {{ oidc_urls.idp.host }}"
environment:
CAROOT: "{{ mkcert_caroot | default(omit) }}"
- name: Move IDP certificate and key to target
ansible.builtin.copy:
src: "{{ idp_cert_temp_dir.path }}/{{ item.src }}"
dest: "{{ item.dest }}"
owner: root
group: "{{ cacert_os_group }}"
mode: "{{ item.mode }}"
remote_src: true
loop:
- {src: idp.pem, dest: "{{ idp_tls.cert }}", mode: '0644'}
- {src: idp.key.pem, dest: "{{ idp_tls.key }}", mode: '0640'}
become: true
- name: Remove temporary directory
ansible.builtin.file:
path: "{{ idp_cert_temp_dir.path }}"
state: absent
when: not idp_cert_st.stat.exists
become: false
- name: Copy client CA certificates
ansible.builtin.copy:
dest: "{{ idp_tls.client_cas }}"
owner: root
group: "{{ cacert_os_group }}"
mode: '0640'
content: "{{ idp.client_certificate_data }}"
- name: Check whether configuration file exists
ansible.builtin.stat:
path: "{{ cacert_home }}/etc/cacert-idp.toml"
register: idp_config_st
- name: Get credentials from existing file
block:
- name: fetch existing configuration file
ansible.builtin.fetch:
src: "{{ idp_config_st.stat.path }}"
dest: idp_config-from-vagrant.toml
flat: true
- name: set credential facts
ansible.builtin.set_fact:
idp_csrf_key: "{{ lookup('ansible.builtin.ini', 'csrf.key', section='security', file='idp_config-from-vagrant.toml') | from_json }}"
idp_auth_key: "{{ lookup('ansible.builtin.ini', 'auth-key', section='session', file='idp_config-from-vagrant.toml') | from_json }}"
idp_enc_key: "{{ lookup('ansible.builtin.ini', 'enc-key', section='session', file='idp_config-from-vagrant.toml') | from_json }}"
when: idp_config_st.stat.exists
- name: Create IDP configuration
ansible.builtin.template:
src: idp_config.toml.j2
dest: "{{ cacert_home }}/etc/cacert-idp.toml"
owner: root
group: "{{ cacert_os_group }}"
mode: '0640'
notify: idp_systemd_reload
- name: Create IDP systemd unit file
ansible.builtin.template:
src: cacert-idp.service.j2
dest: /etc/systemd/system/cacert-idp.service
owner: root
group: root
mode: "0640"
notify: idp_systemd_reload

View file

@ -0,0 +1,14 @@
[Unit]
Description=CAcert OpenID Connect identity provider
After=network.target
Documentation=https://code.cacert.org/cacert/oidc-idp
[Service]
ExecStart={{ cacert_home }}/bin/cacert-idp --conf "{{ cacert_home }}/etc/cacert-idp.toml"
WorkingDirectory={{ cacert_home }}
User={{ cacert_os_user }}
Group={{ cacert_os_group }}
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,19 @@
[security]
csrf.key = "{{ idp_csrf_key | default(lookup('community.general.random_string', length=32, base64=true)) }}"
client.ca-file = "{{ idp_tls.client_cas }}"
[server]
name = "{{ oidc_urls.idp.address | default(ansible_default_ipv4.address) }}"
port = {{ oidc_urls.idp.address | default("3000") }}
certificate = "{{ idp_tls.cert }}"
key = "{{ idp_tls.key }}"
[session]
auth-key = "{{ idp_auth_key | default(lookup('community.general.random_string', length=64, base64=true)) }}"
enc-key = "{{ idp_enc_key | default(lookup('community.general.random_string', length=32, base64=true)) }}"
[admin]
url = "https://{{ oidc_urls.hydra_admin.address | default("hydra.cacert.localhost") }}:{{ oidc_urls.hydra_admin.port | default("3000") }}"
[log]
level = "trace"

View file

@ -0,0 +1,5 @@
---
idp_tls:
cert: "{{ cacert_home }}/etc/{{ oidc_urls.idp.host }}.pem"
key: "{{ cacert_home }}/etc/{{ oidc_urls.idp.host }}-key.pem"
client_cas: "{{ cacert_home }}/etc/{{ oidc_urls.idp.host }}-client-cas.pem"

View file

@ -0,0 +1,14 @@
Role Name
=========
Prepare development tools for the CAcert OIDC setup.
License
-------
GPL-2.0-or-later
Author Information
------------------
Jan Dittberner <jandd@cacert.org>

View file

@ -0,0 +1,19 @@
---
galaxy_info:
author: Jan Dittberner
description: Prepare development tools for the CAcert OIDC setup.
company: CAcert
license: GPL-2.0-or-later
min_ansible_version: 2.1
platforms:
- name: Debian
versions:
- bullseye
- bookworm
galaxy_tags: []
dependencies: []

View file

@ -0,0 +1,18 @@
---
- name: Prepare mkcert
block:
- name: Install mkcert
ansible.builtin.apt:
name: mkcert
update_cache: true
become: true
- name: Install mkcert CA
ansible.builtin.command:
cmd: "mkcert -install"
environment:
CAROOT: "{{ mkcert_caroot | default(omit) }}"
changed_when: false
become: false

@ -1 +1 @@
Subproject commit 4d3f908958b100eb901ce9f849a6fdd613aece06 Subproject commit 6aa5d1de0411ce93deb67d91ed841ec1ef658bc3

@ -1 +1 @@
Subproject commit f980c1acc3f01f40dfea739a7433cb83fed53d98 Subproject commit ae86e52d405f120eacc19f122a599a555a618aeb

@ -1 +1 @@
Subproject commit 2c82ccb324ed370c05bbb874d0cd88f03cd8aa4e Subproject commit 9aeca21faa2db96ecd359e26eb4dc392d7c6bf1a