Skip to content
Closed

2fa #729

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
deea05a
2FA support via Authelia OIDC (#469)
cyberb Feb 25, 2026
f28fc2a
fix login test for OIDC redirect
cyberb Feb 25, 2026
6900a19
fix auth test stub missing IsTwoFactorEnabled method
cyberb Feb 25, 2026
0132351
fix template parser panic on directories in config path
cyberb Feb 25, 2026
7903b79
fix integration test to use OIDC login endpoint
cyberb Feb 25, 2026
dea0e00
add CLI-based token login for non-browser test authentication
cyberb Feb 26, 2026
c590c3a
fix UI tests to use login_v2 token-based authentication
cyberb Feb 26, 2026
3aaf43b
fix UI test to wait for Authelia after activation
cyberb Feb 26, 2026
0a13d9d
fix UI test to look for Authelia login form instead of platform login…
cyberb Feb 26, 2026
635c7fd
fix UI test to add auth host alias on device for OIDC token exchange
cyberb Feb 27, 2026
474cb6c
use unix socket for OIDC token exchange to avoid TLS cert mismatch
cyberb Feb 27, 2026
5c8e34d
fix UI test to clear cookies before auth_web test
cyberb Feb 27, 2026
12cd918
fix 2fa_enable test to skip Authelia login if already authenticated
cyberb Feb 27, 2026
b9e3686
fix UI test to navigate Authelia settings menu for TOTP registration
cyberb Feb 27, 2026
3fd5a15
fix 2FA test: enable platform 2FA first, then register TOTP in Authelia
cyberb Feb 27, 2026
f42a9a2
fix 2FA test: click Register device link before reading verification …
cyberb Feb 27, 2026
976f611
Validate authelia config before replacing live config and restarting
cyberb Feb 27, 2026
b67cedd
Fix authelia validation command: validate-config not validate-configu…
cyberb Feb 28, 2026
e5c5fb7
Fix 2FA test: Register device is a button with id, not an anchor tag
cyberb Feb 28, 2026
20bd3c6
Switch authelia notifier from SMTP to filesystem
cyberb Feb 28, 2026
746c244
Upgrade Authelia to 4.39.15 and fix TOTP registration test
cyberb Feb 28, 2026
717e35f
Fix authelia wrapper to support glibc dynamic linker
cyberb Feb 28, 2026
fe190e4
Fix authelia wrapper: use LD_LIBRARY_PATH instead of explicit ld-linux
cyberb Feb 28, 2026
2df10cc
Fix authelia wrapper to use glibc ld-linux pattern from other projects
cyberb Feb 28, 2026
a29fe2c
Fix authelia wrapper: use find for dynamic linker, add debug logging
cyberb Feb 28, 2026
773368f
Fix authelia packaging: dereference /lib symlink when copying
cyberb Feb 28, 2026
d8c5ba7
Add -v test in authelia build stage, add -x to wrapper for debugging
cyberb Feb 28, 2026
5852920
Switch authelia test to bootstrap image, rename package.sh to build.sh
cyberb Feb 28, 2026
f07bcb4
Test authelia on all distros like nginx
cyberb Feb 28, 2026
d73097f
Fix authelia wrapper library path to include arch subdirectory
cyberb Feb 28, 2026
742a7f4
Add --config.experimental.filters template to authelia validate-config
cyberb Feb 28, 2026
4cd33f4
Create authelia assets directory if copy fails
cyberb Mar 1, 2026
7191ca2
Create authelia assets dir in output path before config validation
cyberb Mar 1, 2026
fb65f48
Fix 2FA test: Authelia 4.39 one-time code is alphanumeric not numeric
cyberb Mar 1, 2026
9d64746
Simplify 2FA one-time code extraction from notification
cyberb Mar 1, 2026
e82ee7c
Fix test_2fa_login: navigate to platform before logout
cyberb Mar 2, 2026
7b5bcd5
Fix test_2fa_login: clear cookies, remove all sleeps from 2fa tests
cyberb Mar 2, 2026
e682cc6
Fix test_2fa_enable: wait for enable to complete before navigating
cyberb Mar 2, 2026
32fe20d
Fix test_2fa_login: clear cookies on both auth and platform domains
cyberb Mar 2, 2026
e5aee95
Fix test_2fa_login: reuse TOTP secret from registration instead of sq…
cyberb Mar 2, 2026
d8b0593
Fix test_2fa_login: use individual OTP inputs instead of single tel i…
cyberb Mar 3, 2026
54438e3
Fix test_2fa_login: wait for next TOTP period to avoid replay rejection
cyberb Mar 3, 2026
623e12d
Fix test_settings_deactivate: clear stale cookies after reactivation
cyberb Mar 3, 2026
1cc36be
Fix test_settings_deactivate: wait for Authelia to be ready after rea…
cyberb Mar 3, 2026
13a1b4b
Fix test_settings_deactivate: clear cookies on device_host domain too
cyberb Mar 3, 2026
aa105ab
Fix test_settings_deactivate: use full_domain for OIDC login flow
cyberb Mar 3, 2026
bc71a80
Fix test_permission_denied: clear Authelia cookies after logout
cyberb Mar 4, 2026
dbc0629
Clear Authelia cookies after all logouts in UI tests
cyberb Mar 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions .drone.jsonnet
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ local selenium = '4.35.0-20250828';
local go = '1.24.0';
local node = '22.16.0';
local deployer = 'https://github.com/syncloud/store/releases/download/4/syncloud-release';
local authelia = '4.38.8';
local authelia = '4.39.15';
local distro_default = 'buster';
local distros = ['bookworm', 'buster'];
local bootstrap = '25.02';
Expand Down Expand Up @@ -48,17 +48,19 @@ local build(arch, testUI) = [{
name: 'authelia',
image: 'authelia/authelia:' + authelia,
commands: [
'./authelia/package.sh',
'./authelia/build.sh',
],

},
] + [
{
name: 'authelia test',
image: 'debian:bookworm-slim',
name: 'authelia test ' + distro,
image: 'syncloud/bootstrap-' + distro + '-' + arch + ':' + bootstrap,
commands: [
'./authelia/test.sh',
],
},
}
for distro in distros
] + [
{
name: 'build web',
image: 'node:' + node,
Expand Down
132 changes: 132 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Debugging CI failures

When a CI build fails, always start by identifying the failing step:
```
curl -s "http://ci.syncloud.org:8080/api/repos/syncloud/platform/builds/{N}" | python3 -c "
import json,sys
b=json.load(sys.stdin)
for stage in b.get('stages',[]):
for step in stage.get('steps',[]):
if step.get('status') == 'failure':
print(step.get('name'), '-', step.get('status'))
"
```

Then get the step log (stage=pipeline index, step=step number):
```
curl -s "http://ci.syncloud.org:8080/api/repos/syncloud/platform/builds/{N}/logs/{stage}/{step}" | python3 -c "
import json,sys; [print(l.get('out',''), end='') for l in json.load(sys.stdin)]
" | tail -80
```

# CI

http://ci.syncloud.org:8080/syncloud/platform

CI is Drone CI (JS SPA). Check builds via API:
```
curl -s "http://ci.syncloud.org:8080/api/repos/syncloud/platform/builds?limit=5"
```

## CI Artifacts

Artifacts are served at `http://ci.syncloud.org:8081` (returns JSON directory listings).

Browse the top level for a build (returns distro subdirs + snap file):
```
curl -s "http://ci.syncloud.org:8081/files/platform/{build}-{arch}/"
```

Each distro dir contains `app/`, `platform/`, and for upgrade/UI tests also `desktop/`, `refresh.journalctl.log`, `video.mkv`:
```
curl -s "http://ci.syncloud.org:8081/files/platform/{build}-{arch}/{distro}/"
curl -s "http://ci.syncloud.org:8081/files/platform/{build}-{arch}/{distro}/app/"
curl -s "http://ci.syncloud.org:8081/files/platform/{build}-{arch}/{distro}/desktop/"
```

Directory structure:
```
{build}-{arch}/
{distro}/
app/
journalctl.log # full journal from integration test teardown
ps.log, netstat.log # process/network state at teardown
platform/ # platform logs
desktop/ # UI test artifacts (amd64 only)
journalctl.log
screenshot/
{test-name}.png
{test-name}.html.log
log/
refresh.journalctl.log # full journal from upgrade test (pre/post-refresh)
video.mkv # selenium recording
```

Download a file directly:
```
curl -O "http://ci.syncloud.org:8081/files/platform/282-amd64/bookworm/refresh.journalctl.log"
curl -O "http://ci.syncloud.org:8081/files/platform/282-amd64/bookworm/app/journalctl.log"
curl -O "http://ci.syncloud.org:8081/files/platform/282-amd64/bookworm/desktop/journalctl.log"
```

# Project Structure

- **Snap-based platform** providing self-hosting OS, app installer, and platform services for Syncloud
- Architectures: amd64, arm64, arm
- Distros: bookworm, buster
- CI pipelines defined in `.drone.jsonnet`

## Key directories

- `backend/` — Go backend services (API server, backend server, CLI, snap hooks)
- `cmd/` — Executables: api, backend, cli, install, post-refresh
- `rest/` — REST API endpoints (gorilla/mux)
- `auth/` — Authentication: OIDC, LDAP, Authelia integration
- `config/` — Configuration management (SQLite)
- `storage/` — Disk/btrfs management
- `installer/` — App installation service
- `backup/` — Backup/restore
- Built with `CGO_ENABLED` and static linking
- `www/` — Vue 3 frontend (Element Plus, Vite, TypeScript)
- `config/` — Configuration templates (authelia, ldap, nginx, errors)
- `authelia/` — Authelia auth server packaging
- `nginx/` — Nginx build/test scripts
- `bin/` — Platform utility scripts
- `meta/snap.yaml` — Snap metadata (services: nginx-public, openldap, backend, api, authelia, cli)
- `test/` — Python integration tests (pytest), Selenium UI tests, Go API tests
- `package.sh` — Creates snap package

## Build pipeline steps (per arch)

1. `version` — writes build number
2. `nginx` / `nginx test {distro}` — build and test nginx (tested on both bookworm and buster)
3. `authelia` / `authelia test` — package and test Authelia auth server
4. `build web` — npm install, test, lint, build Vue frontend
5. `build` — compile Go backend binaries (with `go test ./...` coverage)
6. `build api test` — compile Go API integration test binary
7. `package` — create `.snap` file + test app
8. `test {distro}` — integration tests per distro against bootstrap service containers
9. (amd64 only) `selenium` + `test-ui-desktop` + `test-ui-mobile` — Selenium UI tests
10. `test-upgrade` — upgrade path testing
11. `upload` / `promote` — publish to release repo (stable/master branches only)
12. `artifact` — upload test artifacts via SCP

# Running Drone builds locally

Generate `.drone.yml` from jsonnet (run from project root):
```
drone jsonnet --stdout --stream > .drone.yml
```

Run a specific pipeline with selected steps:
```
drone exec --pipeline amd64 --trusted \
--include version \
--include nginx \
--include authelia \
--include "build web" \
--include build \
--include package \
--include "test bookworm" \
.drone.yml
```
4 changes: 3 additions & 1 deletion authelia/authelia.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#!/bin/bash -e
DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )
${DIR}/lib/ld-musl-*.so* --library-path ${DIR}/lib ${DIR}/authelia "$@"
LD=$(find ${DIR}/lib -name 'ld-*.so*' -type f -print -quit)
LIBS=$(echo ${DIR}/lib/*-linux-gnu*)
exec ${LD} --library-path $LIBS ${DIR}/authelia "$@"
3 changes: 2 additions & 1 deletion authelia/package.sh → authelia/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ cd ${DIR}
BUILD_DIR=${DIR}/../build/snap/authelia
mkdir -p ${BUILD_DIR}
cp /app/authelia ${BUILD_DIR}
cp -r /lib ${BUILD_DIR}
cp -rL /lib ${BUILD_DIR}
cp ${DIR}/authelia.sh ${BUILD_DIR}
${BUILD_DIR}/authelia -v
161 changes: 161 additions & 0 deletions backend/auth/oidc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package auth

import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"go.uber.org/zap"
"io"
"net"
"net/http"
"net/url"
"strings"
)

type OIDCConfig interface {
DeviceUrl() string
Url(app string) string
}

type OIDCService struct {
config OIDCConfig
socketPath string
logger *zap.Logger
}

func NewOIDCService(config OIDCConfig, socketPath string, logger *zap.Logger) *OIDCService {
return &OIDCService{
config: config,
socketPath: socketPath,
logger: logger,
}
}

func (s *OIDCService) GetAuthorizationURL() (authURL string, state string, codeVerifier string, err error) {
state, err = randomString(32)
if err != nil {
return "", "", "", fmt.Errorf("generate state: %w", err)
}

codeVerifier, err = randomString(64)
if err != nil {
return "", "", "", fmt.Errorf("generate code verifier: %w", err)
}

codeChallenge := generateCodeChallenge(codeVerifier)
redirectURI := s.config.DeviceUrl() + "/rest/oidc/callback"
authEndpoint := s.config.Url("auth") + "/api/oidc/authorization"

params := url.Values{
"client_id": {"syncloud"},
"response_type": {"code"},
"redirect_uri": {redirectURI},
"scope": {"openid profile email groups"},
"state": {state},
"code_challenge": {codeChallenge},
"code_challenge_method": {"S256"},
}

authURL = authEndpoint + "?" + params.Encode()
return authURL, state, codeVerifier, nil
}

func (s *OIDCService) ExchangeCode(code string, codeVerifier string) (string, error) {
tokenEndpoint := "http://authelia/api/oidc/token"
redirectURI := s.config.DeviceUrl() + "/rest/oidc/callback"

data := url.Values{
"grant_type": {"authorization_code"},
"client_id": {"syncloud"},
"code": {code},
"redirect_uri": {redirectURI},
"code_verifier": {codeVerifier},
}

client := &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", s.socketPath)
},
},
}
resp, err := client.PostForm(tokenEndpoint, data)
if err != nil {
return "", fmt.Errorf("token request: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read token response: %w", err)
}

if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body))
}

var tokenResponse struct {
IDToken string `json:"id_token"`
}
if err := json.Unmarshal(body, &tokenResponse); err != nil {
return "", fmt.Errorf("parse token response: %w", err)
}

if tokenResponse.IDToken == "" {
return "", fmt.Errorf("no id_token in response")
}

username, err := extractUsernameFromIDToken(tokenResponse.IDToken)
if err != nil {
return "", err
}

return username, nil
}

func extractUsernameFromIDToken(idToken string) (string, error) {
parts := strings.Split(idToken, ".")
if len(parts) != 3 {
return "", fmt.Errorf("invalid id_token format")
}

payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", fmt.Errorf("decode id_token payload: %w", err)
}

var claims struct {
PreferredUsername string `json:"preferred_username"`
Subject string `json:"sub"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return "", fmt.Errorf("parse id_token claims: %w", err)
}

username := claims.PreferredUsername
if username == "" {
username = claims.Subject
}
if username == "" {
return "", fmt.Errorf("no username in id_token")
}

return username, nil
}

func randomString(length int) (string, error) {
bytes := make([]byte, length)
_, err := rand.Read(bytes)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(bytes)[:length], nil
}

func generateCodeChallenge(codeVerifier string) string {
hash := sha256.Sum256([]byte(codeVerifier))
return base64.RawURLEncoding.EncodeToString(hash[:])
}
Loading