Compare commits
4 Commits
7fc497a1a4
...
v0.3.6
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fe7a7e2a3 | |||
| b10dd444b3 | |||
| f3da6ea618 | |||
| ae170670d9 |
54
.gitea/scripts/gen_tree.py
Normal file
54
.gitea/scripts/gen_tree.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Render an ASCII tree of tracked files, read from stdin (one path per line).
|
||||||
|
|
||||||
|
Used by the `sync-project-tree` workflow to regenerate this repo's PROJECT_TREE.md
|
||||||
|
snapshot in the RunicGateway/docs repo. Feed it `git ls-files`:
|
||||||
|
|
||||||
|
git ls-files | python3 .gitea/scripts/gen_tree.py <root-label>
|
||||||
|
|
||||||
|
Deterministic ordering: directories before files, each group sorted
|
||||||
|
case-insensitively with the raw name as a tiebreak. Output uses the classic
|
||||||
|
`tree(1)` box-drawing style so the result is stable across runs and platforms.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def build(paths):
|
||||||
|
root = {}
|
||||||
|
for p in paths:
|
||||||
|
p = p.strip().replace("\\", "/")
|
||||||
|
if not p:
|
||||||
|
continue
|
||||||
|
node = root
|
||||||
|
for part in p.split("/"):
|
||||||
|
node = node.setdefault(part, {})
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def render(node, prefix, lines):
|
||||||
|
entries = list(node.items())
|
||||||
|
# directories (non-empty children dict) before files, then case-insensitive name
|
||||||
|
entries.sort(key=lambda kv: (0 if kv[1] else 1, kv[0].lower(), kv[0]))
|
||||||
|
for i, (name, child) in enumerate(entries):
|
||||||
|
last = i == len(entries) - 1
|
||||||
|
branch = "└── " if last else "├── "
|
||||||
|
suffix = "/" if child else ""
|
||||||
|
lines.append(f"{prefix}{branch}{name}{suffix}")
|
||||||
|
if child:
|
||||||
|
render(child, prefix + (" " if last else "│ "), lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8", newline="\n")
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
root_label = sys.argv[1] if len(sys.argv) > 1 else "."
|
||||||
|
tree = build(sys.stdin.read().splitlines())
|
||||||
|
lines = [f"{root_label}/"]
|
||||||
|
render(tree, "", lines)
|
||||||
|
sys.stdout.write("\n".join(lines) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
111
.gitea/workflows/sync-project-tree.yml
Normal file
111
.gitea/workflows/sync-project-tree.yml
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
name: sync-project-tree
|
||||||
|
|
||||||
|
# Keeps this repo's file-layout snapshot (docs/android/PROJECT_TREE.md in the
|
||||||
|
# RunicGateway/docs repo) current. On every push to `main` it regenerates the
|
||||||
|
# tree from tracked files and, if it changed, opens (or force-updates) a pull
|
||||||
|
# request against the docs repo. It never writes to the docs repo's `main`
|
||||||
|
# directly. Auth reuses the same REGISTRY_USER / REGISTRY_TOKEN secrets the
|
||||||
|
# other workflows use (the token needs repo read/write on RunicGateway/docs).
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: sync-project-tree
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_HOST: gitea.whitlocktech.com
|
||||||
|
DOCS_REPO: RunicGateway/docs
|
||||||
|
SELF_REPO: RunicGateway/Android-app
|
||||||
|
DOCS_PATH: android/PROJECT_TREE.md
|
||||||
|
TREE_TITLE: Android App
|
||||||
|
ROOT_LABEL: android-app
|
||||||
|
PR_BRANCH: chore/sync-android-tree
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out this repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Ensure python3 is available
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
command -v python3 >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq python3; }
|
||||||
|
|
||||||
|
- name: Render PROJECT_TREE.md from tracked files
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p _sync
|
||||||
|
{
|
||||||
|
printf '# %s — Project Tree\n\n' "${TREE_TITLE}"
|
||||||
|
printf '> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in\n'
|
||||||
|
printf '> the [`%s`](https://%s/%s) repository, which\n' "${SELF_REPO}" "${GITEA_HOST}" "${SELF_REPO}"
|
||||||
|
printf '> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit\n'
|
||||||
|
printf '> by hand — changes will be overwritten by the next sync.\n\n'
|
||||||
|
printf 'A snapshot of the tracked files in the repository (build output, dependencies, and other\n'
|
||||||
|
printf 'git-ignored paths are excluded).\n\n'
|
||||||
|
printf '```text\n'
|
||||||
|
git ls-files | python3 .gitea/scripts/gen_tree.py "${ROOT_LABEL}"
|
||||||
|
printf '```\n'
|
||||||
|
} > _sync/PROJECT_TREE.md
|
||||||
|
echo "----- generated ${DOCS_PATH} -----"
|
||||||
|
cat _sync/PROJECT_TREE.md
|
||||||
|
|
||||||
|
- name: Open or update the docs PR if the tree changed
|
||||||
|
env:
|
||||||
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Secrets can carry a trailing CR/LF depending on how they were pasted;
|
||||||
|
# strip line breaks before they land in a URL or Authorization header.
|
||||||
|
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
|
||||||
|
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||||
|
API="https://${GITEA_HOST}/api/v1/repos/${DOCS_REPO}"
|
||||||
|
REMOTE="https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${DOCS_REPO}.git"
|
||||||
|
|
||||||
|
git clone --depth 1 "${REMOTE}" docs_repo
|
||||||
|
cd docs_repo
|
||||||
|
git config user.name "runic-docs-bot"
|
||||||
|
git config user.email "ci@whitlocktech.com"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "${DOCS_PATH}")"
|
||||||
|
cp ../_sync/PROJECT_TREE.md "${DOCS_PATH}"
|
||||||
|
git add "${DOCS_PATH}"
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "PROJECT_TREE.md already up to date — nothing to sync."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
SHORT_SHA="$(echo "${GITHUB_SHA:-local}" | cut -c1-7)"
|
||||||
|
git checkout -B "${PR_BRANCH}"
|
||||||
|
git commit -m "docs(tree): sync ${DOCS_PATH} from ${SELF_REPO}@${SHORT_SHA} [skip ci]"
|
||||||
|
git push --force "${REMOTE}" "HEAD:${PR_BRANCH}"
|
||||||
|
|
||||||
|
# Open a PR only if one isn't already open for this branch (a force-push
|
||||||
|
# to an existing open PR's head updates it in place).
|
||||||
|
OPEN="$(curl -sSf -H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
"${API}/pulls?state=open&limit=50" \
|
||||||
|
| jq --arg b "${PR_BRANCH}" '[.[] | select(.head.ref == $b)] | length')"
|
||||||
|
if [ "${OPEN}" = "0" ]; then
|
||||||
|
curl -sSf -X POST "${API}/pulls" \
|
||||||
|
-H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n \
|
||||||
|
--arg head "${PR_BRANCH}" \
|
||||||
|
--arg base "main" \
|
||||||
|
--arg title "docs(tree): sync ${DOCS_PATH}" \
|
||||||
|
--arg body "Automated project-tree sync from [\`${SELF_REPO}\`](https://${GITEA_HOST}/${SELF_REPO}), regenerated from tracked files on \`main\`. Merge once the layout looks right; the workflow will keep this branch current until then." \
|
||||||
|
'{head: $head, base: $base, title: $title, body: $body}')" \
|
||||||
|
>/dev/null
|
||||||
|
echo "Opened a new docs PR for ${PR_BRANCH}."
|
||||||
|
else
|
||||||
|
echo "Existing open docs PR for ${PR_BRANCH} was updated via force-push."
|
||||||
|
fi
|
||||||
@@ -5,6 +5,7 @@ package com.runicgateway.app.core.auth.sso
|
|||||||
|
|
||||||
import com.runicgateway.app.BuildConfig
|
import com.runicgateway.app.BuildConfig
|
||||||
import com.runicgateway.app.core.auth.SessionManager
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
|
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||||
import com.runicgateway.app.data.api.SsoApi
|
import com.runicgateway.app.data.api.SsoApi
|
||||||
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
|
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
|
||||||
@@ -49,6 +50,7 @@ class SsoAuthManager @Inject constructor(
|
|||||||
private val sessionManager: SessionManager,
|
private val sessionManager: SessionManager,
|
||||||
private val baseUrlHolder: BaseUrlHolder,
|
private val baseUrlHolder: BaseUrlHolder,
|
||||||
private val pendingStore: PendingSsoStore,
|
private val pendingStore: PendingSsoStore,
|
||||||
|
private val trustTokenStore: TrustTokenStore,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** Why an SSO attempt ended, for a friendly inline message on the login screen. */
|
/** Why an SSO attempt ended, for a friendly inline message on the login screen. */
|
||||||
@@ -193,6 +195,14 @@ class SsoAuthManager @Inject constructor(
|
|||||||
_outcome.value = Outcome.Failed(Failure.SERVER)
|
_outcome.value = Outcome.Failed(Failure.SERVER)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// The user ticked "trust this device" on the TOTP form inside the Custom
|
||||||
|
// Tab. That tab's cookie already covers future SSO sign-ins; persisting
|
||||||
|
// the token the exchange handed back is what lets a native PASSWORD login
|
||||||
|
// on this device skip the code too (TRUSTED_DEVICES_MFA.md). Scoped to the
|
||||||
|
// username exactly like the password path, so it is never replayed for a
|
||||||
|
// different account on a shared device. Saved BEFORE onSignedIn so a
|
||||||
|
// process death mid-callback can't lose it.
|
||||||
|
body.trustToken?.let { trustTokenStore.save(body.user.username, it) }
|
||||||
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
|
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
|
||||||
_outcome.value = Outcome.Success
|
_outcome.value = Outcome.Success
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.runicgateway.app.core.auth.Session
|
|||||||
import com.runicgateway.app.core.auth.SessionManager
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
import com.runicgateway.app.core.auth.StoredSession
|
import com.runicgateway.app.core.auth.StoredSession
|
||||||
import com.runicgateway.app.core.auth.TokenStore
|
import com.runicgateway.app.core.auth.TokenStore
|
||||||
|
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||||
import com.runicgateway.app.data.api.SsoApi
|
import com.runicgateway.app.data.api.SsoApi
|
||||||
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
|
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
|
||||||
@@ -45,6 +46,17 @@ class SsoAuthManagerTest {
|
|||||||
override fun clear() { pending = null }
|
override fun clear() { pending = null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** In-memory stand-in for the encrypted trust-token store, scoped by username
|
||||||
|
* the same way the production impl is. */
|
||||||
|
private class FakeTrustTokenStore : TrustTokenStore {
|
||||||
|
var owner: String? = null
|
||||||
|
var token: String? = null
|
||||||
|
override fun tokenFor(username: String): String? =
|
||||||
|
if (owner.equals(username, ignoreCase = true)) token else null
|
||||||
|
override fun save(username: String, token: String) { owner = username; this.token = token }
|
||||||
|
override fun clear() { owner = null; token = null }
|
||||||
|
}
|
||||||
|
|
||||||
/** Records the exchange it was called with and returns a scripted response. */
|
/** Records the exchange it was called with and returns a scripted response. */
|
||||||
private class FakeSsoApi(
|
private class FakeSsoApi(
|
||||||
private val exchangeResult: () -> Response<MobileTokenResponse>,
|
private val exchangeResult: () -> Response<MobileTokenResponse>,
|
||||||
@@ -76,10 +88,11 @@ class SsoAuthManagerTest {
|
|||||||
session: SessionManager,
|
session: SessionManager,
|
||||||
base: String? = "https://shard.example.com/",
|
base: String? = "https://shard.example.com/",
|
||||||
store: PendingSsoStore = FakePendingSsoStore(),
|
store: PendingSsoStore = FakePendingSsoStore(),
|
||||||
|
trust: TrustTokenStore = FakeTrustTokenStore(),
|
||||||
): SsoAuthManager {
|
): SsoAuthManager {
|
||||||
val holder = BaseUrlHolder()
|
val holder = BaseUrlHolder()
|
||||||
if (base != null) holder.set(base.toHttpUrl())
|
if (base != null) holder.set(base.toHttpUrl())
|
||||||
return SsoAuthManager(api, session, holder, store)
|
return SsoAuthManager(api, session, holder, store, trust)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a start URL and pull the generated `state` back out of it. */
|
/** Build a start URL and pull the generated `state` back out of it. */
|
||||||
@@ -120,6 +133,38 @@ class SsoAuthManagerTest {
|
|||||||
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
|
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trusted devices over SSO (TRUSTED_DEVICES_MFA.md). Ticking "trust this device"
|
||||||
|
// on the TOTP form inside the Custom Tab trusts that browser via cookie; the
|
||||||
|
// exchange additionally hands the APP its own token so a native password login
|
||||||
|
// on this device skips the code too. Before this, SSO ignored trust entirely.
|
||||||
|
@Test fun `a trustToken on the exchange response is persisted for the signed-in user`() = runTest {
|
||||||
|
val api = FakeSsoApi { Response.success(tokenPair().copy(trustToken = "opaque-trust")) }
|
||||||
|
val session = SessionManager(FakeTokenStore())
|
||||||
|
val trust = FakeTrustTokenStore()
|
||||||
|
val mgr = managerWith(api, session, trust = trust)
|
||||||
|
|
||||||
|
val state = startAndState(mgr)
|
||||||
|
mgr.complete(state = state, code = "auth-code-1", error = null)
|
||||||
|
|
||||||
|
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
|
||||||
|
assertEquals("opaque-trust", trust.tokenFor("alice"))
|
||||||
|
// Scoped to the account that minted it — never replayed for someone else.
|
||||||
|
assertNull(trust.tokenFor("mallory"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `no trustToken on the response leaves the store untouched`() = runTest {
|
||||||
|
val api = FakeSsoApi { Response.success(tokenPair()) }
|
||||||
|
val session = SessionManager(FakeTokenStore())
|
||||||
|
val trust = FakeTrustTokenStore()
|
||||||
|
val mgr = managerWith(api, session, trust = trust)
|
||||||
|
|
||||||
|
val state = startAndState(mgr)
|
||||||
|
mgr.complete(state = state, code = "auth-code-1", error = null)
|
||||||
|
|
||||||
|
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
|
||||||
|
assertNull(trust.tokenFor("alice"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun `state mismatch fails without exchanging`() = runTest {
|
@Test fun `state mismatch fails without exchanging`() = runTest {
|
||||||
val api = FakeSsoApi { Response.success(tokenPair()) }
|
val api = FakeSsoApi { Response.success(tokenPair()) }
|
||||||
val session = SessionManager(FakeTokenStore())
|
val session = SessionManager(FakeTokenStore())
|
||||||
|
|||||||
Reference in New Issue
Block a user