mirror of
https://github.com/hanzo-templates/expo-auth.git
synced 2026-10-30 20:56:16 +00:00
The catalogue shipped 71 web templates and one EMPTY `mobile` repo, so "build an app" had no starting point on the platform people actually ship to. This is the iOS lane — Expo/React Native and Flutter for cross-platform, SwiftUI for native. The constraint that shaped every file here: an iOS archive needs macOS + Xcode + a signing identity, and our arc fleet has none. hanzoai has 519 registered runners and every one reports `os: linux`; luxfi has 151, same story; zooai has none. The only macOS builds in the org today (hanzoai/world build-desktop.yml) run on GitHub-HOSTED macos-14, which is exactly what the house CI rule says not to do. So `hanzo.yml` declares only what a Linux arc runner can genuinely execute — typecheck + web bundle for Expo, analyze + test + web for Flutter, `swiftc -parse` for SwiftUI — and the macOS archive lives in a separate `workflow_dispatch`-only `ios.yml`. A job pinned to a label no runner advertises would sit queued forever if it fired on push; parking it is honest, a permanently-yellow check is not. The README of every template states the gap and the one command that closes it. Same reasoning on the demos. Expo and Flutter both have a real web target, so `<slug>.hanzo.app` serves the SAME source the simulator runs. SwiftUI has no web target, so those two templates ship with no demo link rather than a screenshot pretending to be one. Upstreams are permissive only and recorded per template: expo/expo templates (MIT), flutter/samples (BSD-3-Clause), XcodeGen (MIT). Second measured gap, found while wiring this up: `hanzo-templates` is not in arcd's `orgs:` list either (hanzoai, luxfi, zooai, parsdao, zenlm), so the Linux jobs would queue with no runner too. Both gaps are one installer re-run, no code change; both are spelled out in every README rather than left to be discovered on the first red build. Third gap, found by probing rather than assuming, and FIXED here: every run in this org died as `startup_failure` with no logs and no annotation. A bare `runs-on: [self-hosted, linux]` probe queued fine while the same job importing hanzoai/ci failed identically at @v1 and at a pinned SHA — so it was never the tag. The org defaulted GITHUB_TOKEN to `read`, and the reusable declares `permissions: packages: write`; a caller cannot exceed the org maximum, so GitHub kills the run before creating a job. hanzo-templates now defaults to `write` like hanzoai/luxfi/zooai, and runs reach `queued` — the honest state, waiting on gap 2.
91 lines
3.2 KiB
TypeScript
91 lines
3.2 KiB
TypeScript
/** Hanzo IAM sign-in: OAuth 2.0 authorization code + PKCE, in the system browser.
|
|
*
|
|
* Deliberately NOT a username/password form. A native app that collects the
|
|
* password owns it — it lands in the JS heap, in accessibility trees, in
|
|
* screenshots — and Apple rejects apps that do this for third-party identity.
|
|
* PKCE keeps the secret out of the app entirely: the app never holds anything
|
|
* a stolen bundle could replay, so no client secret is shipped.
|
|
*
|
|
* Tokens go to expo-secure-store (Keychain on iOS, Keystore on Android),
|
|
* never AsyncStorage.
|
|
*/
|
|
import * as AuthSession from 'expo-auth-session';
|
|
import * as SecureStore from 'expo-secure-store';
|
|
import { useEffect, useSyncExternalStore } from 'react';
|
|
import { Platform } from 'react-native';
|
|
|
|
export const ISSUER = 'https://iam.hanzo.ai';
|
|
export const CLIENT_ID = 'hanzo-mobile'; // register your own <org>-<app> client
|
|
|
|
const KEY = 'hanzo.session';
|
|
|
|
export type Session = { token: string; refresh?: string; sub?: string; email?: string; name?: string };
|
|
|
|
let session: Session | null = null;
|
|
let ready = false;
|
|
const subs = new Set<() => void>();
|
|
const emit = () => subs.forEach((f) => f());
|
|
const snap = () => session;
|
|
|
|
// SecureStore has no web backend; the browser demo keeps the session in memory.
|
|
const store = {
|
|
get: () => (Platform.OS === 'web' ? Promise.resolve(null) : SecureStore.getItemAsync(KEY)),
|
|
set: (v: string) => (Platform.OS === 'web' ? Promise.resolve() : SecureStore.setItemAsync(KEY, v)),
|
|
del: () => (Platform.OS === 'web' ? Promise.resolve() : SecureStore.deleteItemAsync(KEY)),
|
|
};
|
|
|
|
export function useSession() {
|
|
useEffect(() => {
|
|
if (ready) return;
|
|
ready = true;
|
|
store.get().then((raw) => {
|
|
if (raw) { session = JSON.parse(raw); emit(); }
|
|
});
|
|
}, []);
|
|
return useSyncExternalStore((f) => (subs.add(f), () => subs.delete(f)), snap, snap);
|
|
}
|
|
|
|
/** Decodes the id_token payload for display. NOT verification — the token is
|
|
* only ever trusted by the API that checks its signature. */
|
|
const claims = (jwt: string): Partial<Session> => {
|
|
try {
|
|
const b = jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
return JSON.parse(atob(b.padEnd(b.length + ((4 - (b.length % 4)) % 4), '=')));
|
|
} catch { return {}; }
|
|
};
|
|
|
|
export async function signIn(): Promise<void> {
|
|
const discovery = await AuthSession.fetchDiscoveryAsync(ISSUER);
|
|
const redirectUri = AuthSession.makeRedirectUri({ scheme: 'expo-auth', path: 'callback' });
|
|
|
|
const req = new AuthSession.AuthRequest({
|
|
clientId: CLIENT_ID,
|
|
redirectUri,
|
|
scopes: ['openid', 'profile', 'email', 'offline_access'],
|
|
usePKCE: true,
|
|
});
|
|
|
|
const res = await req.promptAsync(discovery);
|
|
if (res.type !== 'success') throw new Error(res.type === 'error' ? res.error?.message ?? 'error' : res.type);
|
|
|
|
const tok = await AuthSession.exchangeCodeAsync(
|
|
{ clientId: CLIENT_ID, code: res.params.code, redirectUri, extraParams: { code_verifier: req.codeVerifier! } },
|
|
discovery,
|
|
);
|
|
|
|
const next: Session = {
|
|
token: tok.accessToken,
|
|
refresh: tok.refreshToken,
|
|
...claims(tok.idToken ?? tok.accessToken),
|
|
};
|
|
session = next;
|
|
await store.set(JSON.stringify(next));
|
|
emit();
|
|
}
|
|
|
|
export async function signOut(): Promise<void> {
|
|
session = null;
|
|
await store.del();
|
|
emit();
|
|
}
|