Fix single sign-on from the web, and make passwords changeable

Why: two questions about operating this turned up two gaps. "Where do I get
the admin password" had no answer once the line scrolled out of the log,
and "how do I set up OIDC" ran straight into a sign-in link that could not
work.

What:
- /oidc/login with no parameters now signs a browser in and ends in a
  session cookie. It previously required the user code of a waiting client,
  so the link on the web sign-in page always failed. With a code it still
  approves a device, and the two paths no longer overlap: an empty user
  code matches no pending device.
- POST /api/v1/auth/password, `keeporg passwd`, and a form on /web/vaults.
  Changing a password requires the current one, and the one-time code when
  a second factor is enrolled, and revokes that account's other sessions.
  Until now nothing could change a password, which made the advice printed
  at first start impossible to follow.
- keeporgd -reset-admin, for when that first password was not kept: a new
  one printed once, any second factor cleared, sessions revoked. It writes
  the state document directly and says to stop the server first.
- The client read stdin through a new buffered reader per prompt, so the
  first prompt swallowed the rest and anything asking twice could not be
  scripted. One shared reader now. A prompt for a password being set no
  longer reads $KEEPORG_PASSWORD either; that variable holds the password
  you sign in with, and taking it here would have set the new password to
  the old one.
- HANDOVER gains a first-sign-in section and an OIDC setup section covering
  the redirect URI, the config block, both entry points and how an identity
  becomes an account.

Proved: OIDC now has tests, against a stub provider that serves discovery,
authorization, token and userinfo. They cover the web sign-in that was
broken, device approval, linking an existing account by username, the
refusal when auto_create is off, and a forged callback state. Eight more
cover the password change. Checked by hand with the real binaries: the
first-start password logs once and works, -reset-admin replaces it while
the old one stops working, and `keeporg passwd` changes it from a pipe.
Server 0.5.0, desktop clients 0.3.0.
This commit is contained in:
2026-10-30 10:47:51 +01:00
parent 0848c0186c
commit 7eed722ff5
18 changed files with 828 additions and 28 deletions
+71 -6
View File
@@ -242,6 +242,39 @@ func printStatus(st []client.Status) {
w.Flush() w.Flush()
} }
func cmdPasswd(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("passwd", flag.ExitOnError)
path := configFlag(fs)
_ = fs.Parse(args)
cfg, api, err := session(*path)
if err != nil {
return err
}
fmt.Printf("Changing the account password for %s on %s.\n", cfg.Username, cfg.ServerURL)
fmt.Println("This is not any vault's password; vault passwords are set in KeePass.")
current, err := readPassword("Current password: ")
if err != nil {
return err
}
next, err := readNewAccountPassword()
if err != nil {
return err
}
err = api.ChangePassword(ctx, current, next, "")
if client.Code(err) == "totp_required" || client.Code(err) == "totp_invalid" {
code, cerr := prompt("One-time code: ")
if cerr != nil {
return cerr
}
err = api.ChangePassword(ctx, current, next, code)
}
if err != nil {
return err
}
fmt.Println("changed; this account's other sessions were signed out")
return nil
}
func cmdTOTP(ctx context.Context, args []string) error { func cmdTOTP(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("totp", flag.ExitOnError) fs := flag.NewFlagSet("totp", flag.ExitOnError)
path := configFlag(fs) path := configFlag(fs)
@@ -566,14 +599,19 @@ func cmdUsers(ctx context.Context, args []string) error {
// ---- terminal helpers ---- // ---- terminal helpers ----
// stdin is read through one buffered reader for the whole process. A
// reader per prompt would discard whatever it had buffered past the first
// line, which loses typed-ahead input and breaks piped input entirely.
var stdin = bufio.NewReader(os.Stdin)
func prompt(label string) (string, error) { func prompt(label string) (string, error) {
fmt.Print(label) fmt.Print(label)
line, err := bufio.NewReader(os.Stdin).ReadString('\n') line, err := stdin.ReadString('\n')
return strings.TrimSpace(line), err return strings.TrimSpace(line), err
} }
// readPassword takes a password without echoing it. It prefers the // readPassword takes the account's existing password without echoing it. It
// environment and a file so that scripts never need a terminal at all. // prefers the environment and a file so that scripts never need a terminal.
func readPassword(label string) (string, error) { func readPassword(label string) (string, error) {
if v := os.Getenv("KEEPORG_PASSWORD"); v != "" { if v := os.Getenv("KEEPORG_PASSWORD"); v != "" {
return v, nil return v, nil
@@ -582,27 +620,54 @@ func readPassword(label string) (string, error) {
b, err := os.ReadFile(f) b, err := os.ReadFile(f)
return strings.TrimSpace(string(b)), err return strings.TrimSpace(string(b)), err
} }
return readNewSecret(label)
}
// readNewSecret asks for a secret that is being set rather than proven, so
// it never reads KEEPORG_PASSWORD: that variable holds the password you are
// signing in with, and taking it here would quietly set the new password to
// the old one.
func readNewSecret(label string) (string, error) {
restore, hidden := hideInput() restore, hidden := hideInput()
defer restore() defer restore()
fmt.Print(label) fmt.Print(label)
line, err := bufio.NewReader(os.Stdin).ReadString('\n') line, err := stdin.ReadString('\n')
if hidden { if hidden {
fmt.Println() fmt.Println()
} }
return strings.TrimSpace(line), err return strings.TrimSpace(line), err
} }
// readNewAccountPassword asks twice for an account password.
func readNewAccountPassword() (string, error) {
first, err := readNewSecret("New password: ")
if err != nil {
return "", err
}
if len(first) < 10 {
return "", errors.New("an account password must be at least 10 characters")
}
again, err := readNewSecret("Repeat: ")
if err != nil {
return "", err
}
if first != again {
return "", errors.New("the two passwords do not match")
}
return first, nil
}
// readNewPassword asks twice, so a vault is never created with a password // readNewPassword asks twice, so a vault is never created with a password
// its owner cannot reproduce. // its owner cannot reproduce.
func readNewPassword(label string) (string, error) { func readNewPassword(label string) (string, error) {
first, err := readPassword(label) first, err := readNewSecret(label)
if err != nil { if err != nil {
return "", err return "", err
} }
if len(first) < 12 { if len(first) < 12 {
return "", errors.New("a vault password must be at least 12 characters") return "", errors.New("a vault password must be at least 12 characters")
} }
again, err := readPassword("Repeat: ") again, err := readNewSecret("Repeat: ")
if err != nil { if err != nil {
return "", err return "", err
} }
+1
View File
@@ -33,6 +33,7 @@ func main() {
{"run", "keep the mirror in sync until stopped", cmdRun}, {"run", "keep the mirror in sync until stopped", cmdRun},
{"sync", "reconcile once and exit", cmdSync}, {"sync", "reconcile once and exit", cmdSync},
{"status", "show each mirrored database", cmdStatus}, {"status", "show each mirrored database", cmdStatus},
{"passwd", "change your account password", cmdPasswd},
{"totp", "enrol a second factor for this account", cmdTOTP}, {"totp", "enrol a second factor for this account", cmdTOTP},
{"whoami", "show the signed-in account and its groups", cmdWhoami}, {"whoami", "show the signed-in account and its groups", cmdWhoami},
{"groups", "list groups you can see", cmdGroups}, {"groups", "list groups you can see", cmdGroups},
+83 -1
View File
@@ -90,7 +90,11 @@ func main() {
showVer = flag.Bool("version", false, "print the version and exit") showVer = flag.Bool("version", false, "print the version and exit")
healthOnly = flag.Bool("healthcheck", false, healthOnly = flag.Bool("healthcheck", false,
"probe a running server on this address and exit 0 if it is healthy") "probe a running server on this address and exit 0 if it is healthy")
writeCfg = flag.String("write-config", "", "write a commented example config to this path and exit") writeCfg = flag.String("write-config", "",
"write a commented example config to this path and exit")
resetAdmin = flag.Bool("reset-admin", false,
"reset the built-in administrator's password, print a new one and "+
"exit; stop the server first")
) )
flag.Parse() flag.Parse()
@@ -139,6 +143,13 @@ func main() {
return return
} }
if *resetAdmin {
if err := resetAdministrator(cfg, lg); err != nil {
lg.Fatalf("reset-admin: %v", err)
}
return
}
srv, err := server.New(cfg, lg) srv, err := server.New(cfg, lg)
if err != nil { if err != nil {
lg.Fatalf("start: %v", err) lg.Fatalf("start: %v", err)
@@ -225,6 +236,77 @@ func bootstrap(srv *server.Server, lg *log.Logger) error {
return nil return nil
} }
// resetAdministrator gives the server's administrator a new password, for
// when the one printed at first start was not kept. It writes the state
// document directly, so the server must be stopped: a running one holds the
// state in memory and would write over this on its next change.
//
// Any second factor on that account is cleared too. Someone who has lost
// the password has often lost the authenticator with it, and a recovery
// that leaves you locked out is not one.
func resetAdministrator(cfg server.Config, lg *log.Logger) error {
cfg.Defaults()
path := filepath.Join(cfg.DataDir, "state.json")
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("no server state at %s: %w", path, err)
}
st, err := store.Open(path)
if err != nil {
return err
}
password := os.Getenv("KEEPORG_ADMIN_PASSWORD")
generated := false
if password == "" {
password = secret.Token()[:20]
generated = true
}
hash, err := secret.Hash(password)
if err != nil {
return err
}
var name string
if err := st.Update(func(s *store.State) error {
u := s.UserByName("admin")
if u == nil {
// Whichever account can administer the server will do.
for _, candidate := range s.Users {
if candidate.ServerAdmin {
u = candidate
break
}
}
}
if u == nil {
u = &store.User{ID: secret.ID(), Username: "admin", CreatedAt: time.Now()}
s.Users[u.ID] = u
}
u.PassHash = hash
u.TOTPSecret, u.TOTPConfirmed = "", false
u.ServerAdmin, u.Disabled = true, false
for token, sess := range s.Sessions {
if sess.UserID == u.ID {
delete(s.Sessions, token)
}
}
name = u.Username
return nil
}); err != nil {
return err
}
if generated {
lg.Printf("reset %q: password %q (shown once). Any second factor on that "+
"account was cleared and its sessions were revoked.", name, password)
} else {
lg.Printf("reset %q to the password from KEEPORG_ADMIN_PASSWORD. Any second "+
"factor on that account was cleared and its sessions were revoked.", name)
}
lg.Print("if keeporgd was running while this ran, restart it now so it reloads the change")
return nil
}
// healthcheck asks a server listening on this configuration whether it is // healthcheck asks a server listening on this configuration whether it is
// serving, and says why not when it is not. // serving, and says why not when it is not.
func healthcheck(cfg server.Config) error { func healthcheck(cfg server.Config) error {
+90 -6
View File
@@ -282,9 +282,64 @@ Three ways in, one kind of credential out: an opaque bearer token stored in
OIDC is implemented against the discovery document with the standard library OIDC is implemented against the discovery document with the standard library
only. The server is a confidential client: it exchanges the authorization only. The server is a confidential client: it exchanges the authorization
code over TLS with the token endpoint and reads the identity from the code over TLS with the token endpoint and reads the identity from the
userinfo endpoint, so there is no local JWT verification to get wrong. An userinfo endpoint, so there is no local JWT verification to get wrong.
identity links to an existing account by `preferred_username` or `email`, or
provisions one when `oidc.auto_create` is set. ### Setting up OIDC
At the identity provider, register a confidential client with one redirect
URI, which is `external_url` plus `/oidc/callback`:
```
https://keeporg.example.com/oidc/callback
```
Then put the issuer and the client credentials in `/etc/keeporg/server.json`
and restart:
```json
"oidc": {
"issuer": "https://id.example.com/realms/main",
"client_id": "keeporg",
"client_secret": "...",
"scopes": "openid profile email",
"auto_create": false,
"default_group_id": ""
}
```
`issuer` is the base URL that answers at
`<issuer>/.well-known/openid-configuration`; everything else is read from
there. All four fields above the blank ones are required — with any of
`issuer`, `client_id` or `client_secret` missing, single sign-on is simply
not offered.
Two entry points use it, and both end at the same callback:
- **A browser**, from the link on `/web`. `/oidc/login` with no parameters
starts a sign-in that ends in a session cookie.
- **A client**, from the device flow. `keeporg login -browser=sso` prints a
short code; the device page at `/device?code=…` offers the same link, this
time as `/oidc/login?code=<user code>`, and approving it releases the
token the waiting client is polling for.
How an identity becomes an account:
1. If some account is already linked to this issuer and `sub`, that one.
2. Otherwise an unlinked local account whose username equals
`preferred_username`, or whose email matches `email`, is linked to it.
This is how someone invited by link later moves to single sign-on.
3. Otherwise, with `auto_create` set, a new account is provisioned and put
in `default_group_id` when one is given. Without it, sign-in is refused
with "no account is linked to this identity; redeem an invite link
first", which is the setting to keep if membership should stay by
invitation.
An account created this way has no password, so it cannot sign in with one
and cannot set one through the password form.
The whole flow is covered by tests against a stub provider in
`internal/server/oidc_test.go`, which is the quickest way to see the
expected request sequence.
## The web interface ## The web interface
@@ -354,9 +409,38 @@ sudo systemctl enable --now keeporgd
sudo journalctl -u keeporgd # the first administrator's password is logged once sudo journalctl -u keeporgd # the first administrator's password is logged once
``` ```
On first start with an empty state the server creates the `admin` account and ### The first sign-in
an `everyone` group, and logs the generated password once. Set
`KEEPORG_ADMIN_PASSWORD` to choose it instead. On first start with an empty state the server creates the `admin` account
and an `everyone` group, and **logs the generated password once**:
```
keeporgd: created the first administrator: username "admin" password "13e4…"
(shown once, change it or enrol TOTP now)
```
Read it with `journalctl -u keeporgd`, or `podman logs <container>`. It is
never written anywhere else: the state file holds only a PBKDF2 verifier.
Set `KEEPORG_ADMIN_PASSWORD` before the first start to choose it instead, in
which case nothing is printed. The variable is read only when the server has
no accounts at all, so leaving it set afterwards does nothing.
Change it once you are in, with `keeporg passwd`, or the form at the bottom
of `/web/vaults`. A change requires the current password, and the one-time
code when a second factor is enrolled, and signs out that account's other
sessions.
**If the password was lost**, stop the server and run:
```sh
sudo -u keeporg keeporgd -data /var/lib/keeporg -reset-admin
```
It prints a new one, clears any second factor on that account, and revokes
its sessions. The server must be stopped: a running one holds the state in
memory and would write over the change. Nothing else is touched, and no
vault is affected — account passwords and vault passwords are different
things.
`/etc/keeporg/server.json`: `/etc/keeporg/server.json`:
+6 -2
View File
@@ -5,8 +5,11 @@ Every `/api/` endpoint takes and returns JSON and needs
endpoints. Errors carry `{"code": "...", "error": "..."}`; `code` is stable endpoints. Errors carry `{"code": "...", "error": "..."}`; `code` is stable
and meant to be matched on, `error` is for humans. and meant to be matched on, `error` is for humans.
HTML is served at `/web` (sign in, list vaults, create one) and at `/device`, HTML is served at `/web` (sign in, list vaults, create one, change your
`/invite/{token}` and `/oidc/callback`. The web pages use the same session password) and at `/device`, `/invite/{token}`, `/oidc/login` and
`/oidc/callback`. `/oidc/login` with no parameters signs a browser in and
ends in a session cookie; with `?code=<user code>` it approves a waiting
client instead and ends in a token that client polls for. The web pages use the same session
tokens in a cookie that is `HttpOnly`, `SameSite=Strict` and `Secure` behind tokens in a cookie that is `HttpOnly`, `SameSite=Strict` and `Secure` behind
https, and every form carries a per-session CSRF token. https, and every form carries a per-session CSRF token.
@@ -17,6 +20,7 @@ https, and every form carries a per-session CSRF token.
| POST | `/api/v1/auth/login` | `{username, password, totp, label}` | `{token, expires_at, user}`; `401 totp_required` when a second factor is enrolled and missing, `401 totp_invalid`, `401 bad_credentials` | | POST | `/api/v1/auth/login` | `{username, password, totp, label}` | `{token, expires_at, user}`; `401 totp_required` when a second factor is enrolled and missing, `401 totp_invalid`, `401 bad_credentials` |
| POST | `/api/v1/auth/logout` | – | revokes the calling token | | POST | `/api/v1/auth/logout` | – | revokes the calling token |
| GET | `/api/v1/auth/whoami` | – | the account and its group memberships | | GET | `/api/v1/auth/whoami` | – | the account and its group memberships |
| POST | `/api/v1/auth/password` | `{current_password, new_password, totp}` | changes the account password; the current one is required, and the second factor when enrolled. Every other session of that account is revoked |
| POST | `/api/v1/auth/totp/enroll` | – | `{secret, uri}`; not active until confirmed | | POST | `/api/v1/auth/totp/enroll` | – | `{secret, uri}`; not active until confirmed |
| POST | `/api/v1/auth/totp/confirm` | `{code}` | `{totp_enabled: true}` | | POST | `/api/v1/auth/totp/confirm` | `{code}` | `{totp_enabled: true}` |
| POST | `/api/v1/auth/device/start` | `{label}` | `{device_code, user_code, verification_url, interval, expires_in}` | | POST | `/api/v1/auth/device/start` | `{label}` | `{device_code, user_code, verification_url, interval, expires_in}` |
+8
View File
@@ -139,6 +139,14 @@ func (a *API) Users(ctx context.Context) ([]User, error) {
return out.Users, err return out.Users, err
} }
// ChangePassword replaces the account password. The server requires the
// current one, and the second factor when one is enrolled, then signs out
// this account's other sessions.
func (a *API) ChangePassword(ctx context.Context, current, next, totp string) error {
return a.do(ctx, http.MethodPost, "/api/v1/auth/password", map[string]string{
"current_password": current, "new_password": next, "totp": totp}, nil)
}
// EnrollTOTP starts second-factor enrolment and returns the shared secret // EnrollTOTP starts second-factor enrolment and returns the shared secret
// and its otpauth URI. // and its otpauth URI.
func (a *API) EnrollTOTP(ctx context.Context) (secret, uri string, err error) { func (a *API) EnrollTOTP(ctx context.Context) (secret, uri string, err error) {
+60
View File
@@ -2,6 +2,7 @@ package server
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -301,6 +302,65 @@ func (s *Server) handleDeviceApprove(w http.ResponseWriter, r *http.Request) {
} }
} }
// changePassword replaces a user's password after proving the current one.
// It is the one place a password changes, so the rules live here: the
// current password is required even though the caller already holds a
// session, the second factor is required when one is enrolled, and every
// other session of that user is revoked, because a password change is what
// someone does when they think a session is no longer theirs.
func changePassword(st *store.State, username, current, totpCode, next, keepToken string) error {
u, err := checkPassword(st, username, current, totpCode)
if err != nil {
return err
}
if len(next) < MinPasswordLen {
return fmt.Errorf("the new password must be at least %d characters", MinPasswordLen)
}
if next == current {
return errors.New("the new password is the same as the old one")
}
hash, err := secret.Hash(next)
if err != nil {
return err
}
u.PassHash = hash
for token, sess := range st.Sessions {
if sess.UserID == u.ID && token != keepToken {
delete(st.Sessions, token)
}
}
return nil
}
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request, u *store.User) {
var req struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
TOTP string `json:"totp"`
}
if !readJSON(w, r, &req) {
return
}
err := s.st.Update(func(st *store.State) error {
return changePassword(st, u.Username, req.CurrentPassword, req.TOTP,
req.NewPassword, bearer(r))
})
switch {
case errors.Is(err, errTOTPRequired):
fail(w, http.StatusUnauthorized, "totp_required", "second factor required")
case errors.Is(err, errTOTPInvalid):
fail(w, http.StatusUnauthorized, "totp_invalid", "second factor rejected")
case errors.Is(err, errBadCredentials):
fail(w, http.StatusUnauthorized, "bad_credentials", "the current password is wrong")
case err != nil:
fail(w, http.StatusBadRequest, "bad_request", err.Error())
default:
writeJSON(w, http.StatusOK, map[string]string{
"status": "password changed; other sessions on this account were signed out",
})
}
}
// ---- invites ---- // ---- invites ----
func (s *Server) handleInvitePage(w http.ResponseWriter, r *http.Request) { func (s *Server) handleInvitePage(w http.ResponseWriter, r *http.Request) {
+18 -4
View File
@@ -30,14 +30,27 @@ type harness struct {
const adminPassword = "admin-password-1" const adminPassword = "admin-password-1"
func newHarness(t *testing.T) *harness { func newHarness(t *testing.T) *harness {
return newHarnessWith(t, nil)
}
// newHarnessWith builds a server whose configuration the caller may adjust,
// for the cases that need OIDC or a different work factor.
func newHarnessWith(t *testing.T, adjust func(*Config)) *harness {
t.Helper() t.Helper()
dir := t.TempDir() dir := t.TempDir()
srv, err := New(Config{ // The listener is created first so the server's external URL is the one
// it actually answers on, which is what an OIDC redirect needs.
ts := httptest.NewUnstartedServer(nil)
cfg := Config{
DataDir: dir, DataDir: dir,
ExternalURL: "http://keeporg.test", ExternalURL: "http://" + ts.Listener.Addr().String(),
MasterKeyFile: filepath.Join(t.TempDir(), "master.key"), MasterKeyFile: filepath.Join(t.TempDir(), "master.key"),
VaultKDF: testKDF, VaultKDF: testKDF,
}, log.New(io.Discard, "", 0)) }
if adjust != nil {
adjust(&cfg)
}
srv, err := New(cfg, log.New(io.Discard, "", 0))
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -54,7 +67,8 @@ func newHarness(t *testing.T) *harness {
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
} }
ts := httptest.NewServer(srv.Handler()) ts.Config = &http.Server{Handler: srv.Handler()}
ts.Start()
t.Cleanup(ts.Close) t.Cleanup(ts.Close)
return &harness{t: t, srv: srv, http: ts, dataDir: dir} return &harness{t: t, srv: srv, http: ts, dataDir: dir}
} }
+24 -1
View File
@@ -165,9 +165,18 @@ func (s *Server) handleOIDCLogin(w http.ResponseWriter, r *http.Request) {
s.renderDone(w, "Single sign-on unavailable", "The identity provider could not be reached.") s.renderDone(w, "Single sign-on unavailable", "The identity provider could not be reached.")
return return
} }
// With a user code this approves a waiting client; without one it signs
// a browser in to the web interface.
userCode := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("code"))) userCode := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("code")))
state := secret.Token() state := secret.Token()
if err := s.st.Update(func(st *store.State) error { if err := s.st.Update(func(st *store.State) error {
if userCode == "" {
st.Devices[state] = &store.DeviceAuth{
DeviceCode: state, Web: true, Label: "web", OIDCState: state,
CreatedAt: time.Now(), ExpiresAt: time.Now().Add(deviceTTL),
}
return nil
}
d := st.DeviceByUserCode(userCode) d := st.DeviceByUserCode(userCode)
if d == nil || time.Now().After(d.ExpiresAt) { if d == nil || time.Now().After(d.ExpiresAt) {
return errors.New("unknown or expired device code") return errors.New("unknown or expired device code")
@@ -223,6 +232,7 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
s.renderDone(w, "Sign-in failed", "The identity provider rejected the sign-in.") s.renderDone(w, "Sign-in failed", "The identity provider rejected the sign-in.")
return return
} }
var webToken string
if err := s.st.Update(func(st *store.State) error { if err := s.st.Update(func(st *store.State) error {
var dev *store.DeviceAuth var dev *store.DeviceAuth
for _, d := range st.Devices { for _, d := range st.Devices {
@@ -238,12 +248,25 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) {
return err return err
} }
dev.OIDCState = "" dev.OIDCState = ""
dev.SessionToken = s.newSession(st, u.ID, dev.Label).Token token := s.newSession(st, u.ID, dev.Label).Token
if dev.Web {
// Nothing is waiting to poll for this one; the browser in front
// of us is the client.
webToken = token
delete(st.Devices, dev.DeviceCode)
return nil
}
dev.SessionToken = token
return nil return nil
}); err != nil { }); err != nil {
s.renderDone(w, "Sign-in failed", err.Error()) s.renderDone(w, "Sign-in failed", err.Error())
return return
} }
if webToken != "" {
s.setWebCookie(w, webToken, s.cfg.SessionTTL)
http.Redirect(w, r, "/web/vaults", http.StatusFound)
return
}
s.renderDone(w, "Device approved", "You can close this page and return to the application.") s.renderDone(w, "Device approved", "You can close this page and return to the application.")
} }
+218
View File
@@ -0,0 +1,218 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"cc.tiredsysadmin.keeporg/internal/store"
)
// fakeIdP is the smallest thing that behaves like an OIDC provider: a
// discovery document, an authorization endpoint that redirects straight
// back, a token endpoint and a userinfo endpoint.
type fakeIdP struct {
*httptest.Server
subject, email, username string
// tokenAuth records the client credentials the server presented.
tokenAuth [2]string
authCalls int
}
func newFakeIdP(t *testing.T, subject, email, username string) *fakeIdP {
idp := &fakeIdP{subject: subject, email: email, username: username}
mux := http.NewServeMux()
idp.Server = httptest.NewServer(mux)
t.Cleanup(idp.Close)
mux.HandleFunc("GET /.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{
"issuer": idp.URL,
"authorization_endpoint": idp.URL + "/authorize",
"token_endpoint": idp.URL + "/token",
"userinfo_endpoint": idp.URL + "/userinfo",
})
})
mux.HandleFunc("GET /authorize", func(w http.ResponseWriter, r *http.Request) {
idp.authCalls++
q := r.URL.Query()
back, err := url.Parse(q.Get("redirect_uri"))
if err != nil {
http.Error(w, "bad redirect_uri", http.StatusBadRequest)
return
}
rq := back.Query()
rq.Set("code", "authorization-code")
rq.Set("state", q.Get("state"))
back.RawQuery = rq.Encode()
http.Redirect(w, r, back.String(), http.StatusFound)
})
mux.HandleFunc("POST /token", func(w http.ResponseWriter, r *http.Request) {
user, pass, _ := r.BasicAuth()
idp.tokenAuth = [2]string{user, pass}
_ = r.ParseForm()
if r.FormValue("code") != "authorization-code" {
http.Error(w, "bad code", http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "an-access-token", "token_type": "Bearer", "expires_in": 300,
})
})
mux.HandleFunc("GET /userinfo", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer an-access-token" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
_ = json.NewEncoder(w).Encode(map[string]string{
"sub": idp.subject, "email": idp.email, "preferred_username": idp.username,
})
})
return idp
}
// withOIDC configures a harness against this provider.
func (idp *fakeIdP) withOIDC(autoCreate bool, defaultGroup string) func(*Config) {
return func(c *Config) {
c.OIDC = OIDCConfig{
Issuer: idp.URL, ClientID: "keeporg", ClientSecret: "a-client-secret",
AutoCreate: autoCreate, DefaultGroupID: defaultGroup,
}
}
}
func TestOIDCSignsInToTheWebInterface(t *testing.T) {
idp := newFakeIdP(t, "subject-1", "sso@example.com", "ssouser")
h := newHarnessWith(t, idp.withOIDC(true, "everyone"))
b := newBrowser(t, h)
// The sign-in page offers it, and following that link is the whole flow.
status, page := b.get("/web")
if status != http.StatusOK || !strings.Contains(page, "/oidc/login") {
t.Fatalf("the sign-in page does not offer single sign-on:\n%s", page)
}
status, page = b.get("/oidc/login")
if status != http.StatusOK {
t.Fatalf("oidc login: %d\n%s", status, page)
}
if !strings.Contains(page, "Your vaults") {
t.Fatalf("single sign-on did not land on the vault list:\n%s", page)
}
if b.cookie() == "" {
t.Fatal("no session cookie after signing in with single sign-on")
}
if idp.authCalls != 1 {
t.Fatalf("the provider's authorize endpoint was called %d times", idp.authCalls)
}
if idp.tokenAuth != [2]string{"keeporg", "a-client-secret"} {
t.Fatalf("the server authenticated to the token endpoint as %v", idp.tokenAuth)
}
// The account was provisioned, linked to the subject, and put in the
// configured group.
var linked *store.User
h.srv.Store().Read(func(st *store.State) {
linked = st.UserByOIDC(idp.URL, "subject-1")
})
if linked == nil {
t.Fatal("no account was linked to the identity")
}
if linked.Username != "ssouser" || linked.Email != "sso@example.com" {
t.Fatalf("provisioned account = %+v", linked)
}
var inGroup bool
h.srv.Store().Read(func(st *store.State) {
for _, m := range st.Memberships {
if m.UserID == linked.ID && m.GroupID == "everyone" {
inGroup = true
}
}
})
if !inGroup {
t.Fatal("the new account did not join the default group")
}
}
func TestOIDCApprovesAWaitingClient(t *testing.T) {
idp := newFakeIdP(t, "subject-2", "dev@example.com", "devuser")
h := newHarnessWith(t, idp.withOIDC(true, "everyone"))
status, start := h.call("POST", "/api/v1/auth/device/start", "", map[string]string{"label": "laptop"})
if status != http.StatusOK {
t.Fatalf("device start: %d %v", status, start)
}
userCode := start["user_code"].(string)
b := newBrowser(t, h)
status, page := b.get("/oidc/login?code=" + url.QueryEscape(userCode))
if status != http.StatusOK || !strings.Contains(page, "Device approved") {
t.Fatalf("device approval through single sign-on: %d\n%s", status, page)
}
// A device approval must not also hand the browser a session cookie.
if b.cookie() != "" {
t.Fatal("approving a device set a web session cookie")
}
status, body := h.call("POST", "/api/v1/auth/device/poll", "",
map[string]string{"device_code": start["device_code"].(string)})
if status != http.StatusOK || body["token"] == "" {
t.Fatalf("poll after single sign-on: %d %v", status, body)
}
if u := body["user"].(map[string]any); u["username"] != "devuser" {
t.Fatalf("the session belongs to %v", u["username"])
}
}
func TestOIDCLinksAnExistingAccountByUsername(t *testing.T) {
idp := newFakeIdP(t, "subject-3", "", "admin")
h := newHarnessWith(t, idp.withOIDC(false, ""))
b := newBrowser(t, h)
if _, page := b.get("/oidc/login"); !strings.Contains(page, "Your vaults") {
t.Fatalf("an existing account was not matched:\n%s", page)
}
var u *store.User
h.srv.Store().Read(func(st *store.State) { u = st.Users["admin"] })
if u.OIDCSubject != "subject-3" {
t.Fatalf("the existing account was not linked: %+v", u)
}
}
func TestOIDCRefusesAnUnknownIdentityWhenProvisioningIsOff(t *testing.T) {
idp := newFakeIdP(t, "subject-4", "stranger@example.com", "stranger")
h := newHarnessWith(t, idp.withOIDC(false, ""))
b := newBrowser(t, h)
_, page := b.get("/oidc/login")
if !strings.Contains(page, "no account is linked") {
t.Fatalf("an unknown identity was let in:\n%s", page)
}
if b.cookie() != "" {
t.Fatal("a session cookie was issued to an unknown identity")
}
}
func TestOIDCCallbackRejectsAnUnknownState(t *testing.T) {
idp := newFakeIdP(t, "subject-5", "", "someone")
h := newHarnessWith(t, idp.withOIDC(true, ""))
b := newBrowser(t, h)
_, page := b.get("/oidc/callback?code=authorization-code&state=not-a-real-state")
if !strings.Contains(page, "expired") {
t.Fatalf("a forged callback was accepted:\n%s", page)
}
if b.cookie() != "" {
t.Fatal("a forged callback issued a session cookie")
}
}
func TestSignInPageHidesSingleSignOnWhenItIsNotConfigured(t *testing.T) {
b := newBrowser(t, newHarness(t))
_, page := b.get("/web")
if strings.Contains(page, "oidc/login") {
t.Fatal("single sign-on is offered although no provider is configured")
}
}
+21
View File
@@ -32,6 +32,9 @@ background:var(--accent);border:0;border-radius:8px;cursor:pointer}
.err{margin:0 0 1rem;padding:.6rem .7rem;border-radius:8px;background:#f8d7da;color:#842029;font-size:.9rem} .err{margin:0 0 1rem;padding:.6rem .7rem;border-radius:8px;background:#f8d7da;color:#842029;font-size:.9rem}
.alt{display:block;margin-top:1rem;text-align:center;font-size:.9rem;color:var(--accent)} .alt{display:block;margin-top:1rem;text-align:center;font-size:.9rem;color:var(--accent)}
code{font-size:1.1rem;letter-spacing:.08em} code{font-size:1.1rem;letter-spacing:.08em}
details{margin-top:1.5rem;border-top:1px solid var(--line);padding-top:1rem}
summary{cursor:pointer;font-size:.9rem;font-weight:600}
details p.sub{margin:.6rem 0 0}
header{display:flex;align-items:baseline;justify-content:space-between;gap:1rem;flex-wrap:wrap} header{display:flex;align-items:baseline;justify-content:space-between;gap:1rem;flex-wrap:wrap}
header form{margin:0} header form{margin:0}
header button{width:auto;margin:0;padding:.35rem .7rem;font-size:.85rem;font-weight:500; header button{width:auto;margin:0;padding:.35rem .7rem;font-size:.85rem;font-weight:500;
@@ -157,6 +160,24 @@ by whoever controls the server. Any other setting keeps the key off the server.<
<p class="empty">You do not administer a group yet, so there is nowhere to put a <p class="empty">You do not administer a group yet, so there is nowhere to put a
vault. Ask an administrator to add you to one.</p> vault. Ask an administrator to add you to one.</p>
{{end}} {{end}}
<details>
<summary>Change your account password</summary>
<p class="sub">This is the password you sign in with, not any vault's password.
Changing it signs out your other sessions.</p>
<form method="post" action="/web/password">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label for="current_password">Current password</label>
<input id="current_password" name="current_password" type="password" autocomplete="current-password" required>
<label for="cp_totp">One-time code <small>(if enabled)</small></label>
<input id="cp_totp" name="totp" inputmode="numeric" autocomplete="one-time-code">
<label for="new_password">New password</label>
<input id="new_password" name="new_password" type="password" autocomplete="new-password" minlength="10" required>
<label for="new_password2">Repeat new password</label>
<input id="new_password2" name="new_password2" type="password" autocomplete="new-password" minlength="10" required>
<button type="submit">Change password</button>
</form>
</details>
{{end}}`)) {{end}}`))
var doneTmpl = template.Must(template.Must(pages.Clone()).Parse(` var doneTmpl = template.Must(template.Must(pages.Clone()).Parse(`
+145
View File
@@ -0,0 +1,145 @@
package server
import (
"net/http"
"net/url"
"strings"
"testing"
"time"
"cc.tiredsysadmin.keeporg/internal/store"
"cc.tiredsysadmin.keeporg/internal/totp"
)
const newAdminPassword = "a-much-better-password"
func TestChangePasswordRequiresTheCurrentOne(t *testing.T) {
h := newHarness(t)
tok, _, _ := h.login("admin", adminPassword, "")
status, body := h.call("POST", "/api/v1/auth/password", tok, map[string]string{
"current_password": "not-the-password", "new_password": newAdminPassword})
if status != http.StatusUnauthorized || body["code"] != "bad_credentials" {
t.Fatalf("wrong current password: %d %v", status, body)
}
// And the password really did not change.
if _, s, _ := h.login("admin", adminPassword, ""); s != http.StatusOK {
t.Fatal("the old password stopped working after a failed change")
}
}
func TestChangePasswordRejectsAWeakOrUnchangedPassword(t *testing.T) {
h := newHarness(t)
tok, _, _ := h.login("admin", adminPassword, "")
for _, next := range []string{"short", adminPassword} {
status, body := h.call("POST", "/api/v1/auth/password", tok, map[string]string{
"current_password": adminPassword, "new_password": next})
if status != http.StatusBadRequest {
t.Fatalf("new password %q: %d %v", next, status, body)
}
}
}
func TestChangePasswordSwapsThePasswordAndRevokesOtherSessions(t *testing.T) {
h := newHarness(t)
keep, _, _ := h.login("admin", adminPassword, "")
elsewhere, _, _ := h.login("admin", adminPassword, "")
status, body := h.call("POST", "/api/v1/auth/password", keep, map[string]string{
"current_password": adminPassword, "new_password": newAdminPassword})
if status != http.StatusOK {
t.Fatalf("change: %d %v", status, body)
}
if _, s, _ := h.login("admin", adminPassword, ""); s != http.StatusUnauthorized {
t.Fatal("the old password still works")
}
if _, s, _ := h.login("admin", newAdminPassword, ""); s != http.StatusOK {
t.Fatal("the new password does not work")
}
// The session that made the change survives; the other one does not.
if s, _ := h.call("GET", "/api/v1/auth/whoami", keep, nil); s != http.StatusOK {
t.Fatalf("the calling session was revoked: %d", s)
}
if s, _ := h.call("GET", "/api/v1/auth/whoami", elsewhere, nil); s != http.StatusUnauthorized {
t.Fatalf("another session survived the password change: %d", s)
}
}
func TestChangePasswordRequiresTheSecondFactorWhenEnrolled(t *testing.T) {
h := newHarness(t)
tok, _, _ := h.login("admin", adminPassword, "")
_, enroll := h.call("POST", "/api/v1/auth/totp/enroll", tok, nil)
sec := enroll["secret"].(string)
code, _ := totp.Code(sec, time.Now())
if s, _ := h.call("POST", "/api/v1/auth/totp/confirm", tok,
map[string]string{"code": code}); s != http.StatusOK {
t.Fatal("could not enrol the second factor")
}
status, body := h.call("POST", "/api/v1/auth/password", tok, map[string]string{
"current_password": adminPassword, "new_password": newAdminPassword})
if status != http.StatusUnauthorized || body["code"] != "totp_required" {
t.Fatalf("change without a code: %d %v", status, body)
}
code, _ = totp.Code(sec, time.Now())
if s, b := h.call("POST", "/api/v1/auth/password", tok, map[string]string{
"current_password": adminPassword, "new_password": newAdminPassword,
"totp": code}); s != http.StatusOK {
t.Fatalf("change with a code: %d %v", s, b)
}
}
func TestWebChangesThePassword(t *testing.T) {
h := newHarness(t)
b := newBrowser(t, h)
csrf := b.signIn()
// The form is on the page, and it is guarded like the others.
_, page := b.get("/web/vaults")
if !strings.Contains(page, "Change your account password") {
t.Fatalf("no password form on the vault page:\n%s", page)
}
if status, _ := b.post("/web/password", url.Values{
"current_password": {adminPassword}, "new_password": {newAdminPassword},
"new_password2": {newAdminPassword}}); status != http.StatusForbidden {
t.Fatalf("a form without a CSRF token returned %d", status)
}
_, page = b.post("/web/password", url.Values{"csrf": {csrf},
"current_password": {adminPassword}, "new_password": {newAdminPassword},
"new_password2": {"something-else"}})
if !strings.Contains(page, "do not match") {
t.Fatalf("mismatch not reported:\n%s", page)
}
_, page = b.post("/web/password", url.Values{"csrf": {csrf},
"current_password": {adminPassword}, "new_password": {newAdminPassword},
"new_password2": {newAdminPassword}})
if !strings.Contains(page, "Password changed") {
t.Fatalf("no confirmation:\n%s", page)
}
if _, s, _ := h.login("admin", newAdminPassword, ""); s != http.StatusOK {
t.Fatal("the new password does not work")
}
}
// An account that exists only through single sign-on has no password to
// change, and must not be able to acquire one this way.
func TestChangePasswordRefusesAnSSOOnlyAccount(t *testing.T) {
h := newHarness(t)
if err := h.srv.Store().Update(func(st *store.State) error {
st.Users["sso"] = &store.User{ID: "sso", Username: "ssouser",
OIDCIssuer: "https://id.example", OIDCSubject: "s"}
st.Sessions["sso-token"] = &store.Session{Token: "sso-token", UserID: "sso",
ExpiresAt: time.Now().Add(time.Hour)}
return nil
}); err != nil {
t.Fatal(err)
}
status, body := h.call("POST", "/api/v1/auth/password", "sso-token", map[string]string{
"current_password": "", "new_password": newAdminPassword})
if status != http.StatusUnauthorized {
t.Fatalf("an account with no password set one: %d %v", status, body)
}
}
+2
View File
@@ -174,6 +174,7 @@ func (s *Server) routes() {
m.HandleFunc("POST /api/v1/auth/login", s.handleLogin) m.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
m.HandleFunc("POST /api/v1/auth/logout", s.authed(s.handleLogout)) m.HandleFunc("POST /api/v1/auth/logout", s.authed(s.handleLogout))
m.HandleFunc("GET /api/v1/auth/whoami", s.authed(s.handleWhoami)) m.HandleFunc("GET /api/v1/auth/whoami", s.authed(s.handleWhoami))
m.HandleFunc("POST /api/v1/auth/password", s.authed(s.handleChangePassword))
m.HandleFunc("POST /api/v1/auth/totp/enroll", s.authed(s.handleTOTPEnroll)) m.HandleFunc("POST /api/v1/auth/totp/enroll", s.authed(s.handleTOTPEnroll))
m.HandleFunc("POST /api/v1/auth/totp/confirm", s.authed(s.handleTOTPConfirm)) m.HandleFunc("POST /api/v1/auth/totp/confirm", s.authed(s.handleTOTPConfirm))
m.HandleFunc("POST /api/v1/auth/device/start", s.handleDeviceStart) m.HandleFunc("POST /api/v1/auth/device/start", s.handleDeviceStart)
@@ -188,6 +189,7 @@ func (s *Server) routes() {
m.HandleFunc("POST /web/signout", s.handleWebSignOut) m.HandleFunc("POST /web/signout", s.handleWebSignOut)
m.HandleFunc("GET /web/vaults", s.handleWebVaults) m.HandleFunc("GET /web/vaults", s.handleWebVaults)
m.HandleFunc("POST /web/vaults", s.handleWebCreateVault) m.HandleFunc("POST /web/vaults", s.handleWebCreateVault)
m.HandleFunc("POST /web/password", s.handleWebChangePassword)
// Browser-facing pages: device approval, invite registration, OIDC. // Browser-facing pages: device approval, invite registration, OIDC.
m.HandleFunc("GET /device", s.handleDevicePage) m.HandleFunc("GET /device", s.handleDevicePage)
+39
View File
@@ -176,6 +176,45 @@ func (s *Server) handleWebCreateVault(w http.ResponseWriter, r *http.Request) {
"with the password you just set.") "with the password you just set.")
} }
func (s *Server) handleWebChangePassword(w http.ResponseWriter, r *http.Request) {
sess := s.webUser(r)
if sess == nil {
s.renderSignIn(w, "")
return
}
if err := r.ParseForm(); err != nil || !s.checkCSRF(r, sess) {
fail(w, http.StatusForbidden, "csrf", "stale form, reload the page")
return
}
next := r.FormValue("new_password")
if next != r.FormValue("new_password2") {
s.renderVaults(w, sess, "The two new passwords do not match.", "")
return
}
cookie, _ := r.Cookie(webCookie)
keep := ""
if cookie != nil {
keep = cookie.Value
}
err := s.st.Update(func(st *store.State) error {
return changePassword(st, sess.user.Username, r.FormValue("current_password"),
r.FormValue("totp"), next, keep)
})
switch {
case errors.Is(err, errTOTPRequired):
s.renderVaults(w, sess, "Enter your one-time code to change the password.", "")
case errors.Is(err, errTOTPInvalid):
s.renderVaults(w, sess, "That one-time code was rejected.", "")
case errors.Is(err, errBadCredentials):
s.renderVaults(w, sess, "The current password is wrong.", "")
case err != nil:
s.renderVaults(w, sess, err.Error(), "")
default:
s.renderVaults(w, sess, "",
"Password changed. Other sessions on this account were signed out.")
}
}
// humanError keeps internal wording out of the browser. // humanError keeps internal wording out of the browser.
func humanError(err error) string { func humanError(err error) string {
if errors.Is(err, errForbidden) { if errors.Is(err, errForbidden) {
+14 -3
View File
@@ -154,8 +154,14 @@ type Session struct {
// Android: the client shows a short user code, the browser approves it, the // Android: the client shows a short user code, the browser approves it, the
// client polls until a session token appears. // client polls until a session token appears.
type DeviceAuth struct { type DeviceAuth struct {
DeviceCode string `json:"device_code"` DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"` // UserCode is the short code a person reads out to approve a client.
// A browser signing in to the web interface has none: there is no
// second device to approve.
UserCode string `json:"user_code"`
// Web marks a sign-in that ends in a cookie rather than in a token a
// waiting client polls for.
Web bool `json:"web,omitempty"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"`
SessionToken string `json:"session_token,omitempty"` SessionToken string `json:"session_token,omitempty"`
OIDCState string `json:"oidc_state,omitempty"` OIDCState string `json:"oidc_state,omitempty"`
@@ -227,8 +233,13 @@ func (s *State) UserByOIDC(issuer, subject string) *User {
return nil return nil
} }
// DeviceByUserCode finds a pending device login by the code shown to the user. // DeviceByUserCode finds a pending device login by the code shown to the
// user. An empty code matches nothing, so a web sign-in, which has no user
// code, is never mistaken for a device waiting to be approved.
func (s *State) DeviceByUserCode(code string) *DeviceAuth { func (s *State) DeviceByUserCode(code string) *DeviceAuth {
if code == "" {
return nil
}
for _, d := range s.Devices { for _, d := range s.Devices {
if d.UserCode == code { if d.UserCode == code {
return d return d
+9
View File
@@ -1,3 +1,12 @@
keeporg (0.3.0) unstable; urgency=medium
* New `keeporg passwd` command, which changes the account password.
* Prompts no longer discard typed-ahead or piped input. Every prompt made
its own buffered reader, so the first one swallowed the rest of stdin
and any command that asked twice was unusable from a script.
-- tiredsysadmin <rcmd@garage.rcmd.space> Fri, 30 Oct 2026 10:45:31 +0100
keeporg (0.2.0) unstable; urgency=medium keeporg (0.2.0) unstable; urgency=medium
* Server-side merging. A vault the server can open has its two concurrent * Server-side merging. A vault the server can open has its two concurrent
+14
View File
@@ -1,3 +1,17 @@
keeporgd (0.5.0) unstable; urgency=medium
* Single sign-on now works from the web interface. The link on the
sign-in page started a flow that only a waiting client could finish, so
it always failed; a browser sign-in now ends in a session cookie.
* New endpoint for changing an account password. It requires the current
password, and the one-time code when a second factor is enrolled, and
revokes that account's other sessions.
* New keeporgd -reset-admin, for when the password printed at first start
was not kept. It prints a new one, clears any second factor on that
account and revokes its sessions. The server must be stopped first.
-- tiredsysadmin <rcmd@garage.rcmd.space> Fri, 30 Oct 2026 10:45:31 +0100
keeporgd (0.4.0) unstable; urgency=medium keeporgd (0.4.0) unstable; urgency=medium
* Read the master key from the file named by KEEPORG_MASTER_KEY_FILE, the * Read the master key from the file named by KEEPORG_MASTER_KEY_FILE, the
+5 -5
View File
@@ -1,10 +1,10 @@
# Single source of truth for all platform versions (semver). # Single source of truth for all platform versions (semver).
# Bump with: make bump PLATFORM=<server|linux|macos|windows|bsd|android> PART=<major|minor|patch> # Bump with: make bump PLATFORM=<server|linux|macos|windows|bsd|android> PART=<major|minor|patch>
VERSION_SERVER := 0.4.0 VERSION_SERVER := 0.5.0
VERSION_LINUX := 0.2.0 VERSION_LINUX := 0.3.0
VERSION_MACOS := 0.2.0 VERSION_MACOS := 0.3.0
VERSION_WINDOWS := 0.2.0 VERSION_WINDOWS := 0.3.0
VERSION_BSD := 0.2.0 VERSION_BSD := 0.3.0
VERSION_ANDROID := 0.2.0 VERSION_ANDROID := 0.2.0
# Monotonic Android versionCode, never decreases. # Monotonic Android versionCode, never decreases.
ANDROID_VERSION_CODE := 2 ANDROID_VERSION_CODE := 2