@edcalderon/auth 1.2.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.2] - 2026-03-19
4
+
5
+ ### Added
6
+
7
+ - Added `packages/auth/supabase/` SQL templates for a vendor-independent `public.users` table and optional Supabase Auth sync trigger.
8
+
9
+ ### Fixed
10
+
11
+ - Hardened the OIDC upsert migration so identity writes require a trusted server-side caller instead of the `anon` role.
12
+ - Preserved existing user profile fields when optional claims are omitted during upserts or Supabase sync updates.
13
+
3
14
  ## [1.2.1] - 2026-03-02
4
15
 
5
16
  ### Changed
package/README.md CHANGED
@@ -11,12 +11,16 @@ Swap between Supabase, Firebase, Hybrid, or any custom provider without changing
11
11
 
12
12
  ---
13
13
 
14
- ## 📋 Latest Changes (v1.2.1)
14
+ ## 📋 Latest Changes (v1.2.2)
15
15
 
16
- ### Changed
16
+ ### Added
17
17
 
18
- - 🎨 Upgraded README badges and title to `for-the-badge` style with gold (#C8A84E) / dark (#0d1117) Alternun brand colors
19
- - 🔗 Added Web3 SIWE|SIWS badge linking to Supabase docs
18
+ - Added `packages/auth/supabase/` SQL templates for a vendor-independent `public.users` table and optional Supabase Auth sync trigger.
19
+
20
+ ### Fixed
21
+
22
+ - Hardened the OIDC upsert migration so identity writes require a trusted server-side caller instead of the `anon` role.
23
+ - Preserved existing user profile fields when optional claims are omitted during upserts or Supabase sync updates.
20
24
 
21
25
  For full version history, see [CHANGELOG.md](./CHANGELOG.md) and [GitHub releases](https://github.com/edcalderon/my-second-brain/releases)
22
26
 
@@ -68,6 +72,13 @@ pnpm add firebase
68
72
  pnpm add react-native
69
73
  ```
70
74
 
75
+ ### Supabase SQL Templates
76
+
77
+ If you want an application-owned user table instead of coupling your identity model to `auth.users`, copy the reference SQL templates in `packages/auth/supabase/` into your Supabase project and apply them with `supabase db push`.
78
+
79
+ - `001_create_app_users.sql`: vendor-independent `public.users` table plus secure server-side OIDC upsert RPC
80
+ - `002_sync_auth_users_to_app_users.sql`: optional trigger and backfill for projects using Supabase Auth
81
+
71
82
  ---
72
83
 
73
84
  ## Subpath Exports (Crucial for RN/Next.js compatibility)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edcalderon/auth",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "description": "A universal, provider-agnostic authentication package (Web + Next.js + Expo/React Native)",
5
5
  "exports": {
6
6
  ".": {
@@ -0,0 +1,65 @@
1
+ # @edcalderon/auth Supabase SQL Templates
2
+
3
+ Copy these files into your own Supabase project. They are reference migrations for projects using @edcalderon/auth, not runtime imports.
4
+
5
+ ## Quick Setups
6
+
7
+ | Use case | Apply | Result |
8
+ | --- | --- | --- |
9
+ | Supabase Auth quick setup | `001` + `002` | Vendor-independent `public.users` plus automatic `auth.users -> public.users` sync |
10
+ | External OIDC only | `001` | Vendor-independent `public.users` plus a secure server-side upsert RPC |
11
+
12
+ ## Files
13
+
14
+ | File | Purpose |
15
+ | --- | --- |
16
+ | `migrations/001_create_app_users.sql` | Creates `public.users`, timestamps trigger, and `upsert_oidc_user()` |
17
+ | `migrations/002_sync_auth_users_to_app_users.sql` | Optional trigger/backfill for projects that use Supabase Auth |
18
+
19
+ ## What You Get
20
+
21
+ - A vendor-independent `public.users` table keyed by `(sub, iss)`.
22
+ - A clean migration path away from Supabase Auth later.
23
+ - An optional trigger that mirrors Supabase Auth users automatically.
24
+ - Safe default behavior for optional profile fields so missing claims do not erase stored data.
25
+
26
+ ## Security Model
27
+
28
+ `upsert_oidc_user()` is intentionally not callable by the `anon` role.
29
+
30
+ - Supabase Auth users are synced automatically by migration `002`.
31
+ - External OIDC users should be written by a trusted server or Edge Function after token verification.
32
+ - Optional fields such as `email`, `name`, `picture`, and `provider` only overwrite stored values when a non-null value is provided.
33
+
34
+ ## Apply
35
+
36
+ ```bash
37
+ # Copy into your project
38
+ cp packages/auth/supabase/migrations/*.sql your-project/supabase/migrations/
39
+
40
+ # Apply them with the Supabase CLI
41
+ supabase db push
42
+ ```
43
+
44
+ If you only want the vendor-independent table and are not using Supabase Auth as a provider, apply only `001_create_app_users.sql`.
45
+
46
+ ## Suggested External OIDC Flow
47
+
48
+ 1. Verify the provider token in your server or Edge Function.
49
+ 2. Extract `sub`, `iss`, and optional claims.
50
+ 3. Call `upsert_oidc_user()` with the service-role key.
51
+ 4. Use the returned `public.users.id` as your application user identifier.
52
+
53
+ ## Why `(sub, iss)`
54
+
55
+ The `(sub, iss)` pair follows the OIDC identity model and allows multiple providers to coexist safely:
56
+
57
+ | Provider | `sub` | `iss` |
58
+ | --- | --- | --- |
59
+ | Supabase Auth | `auth.users.id` | `supabase` |
60
+ | Authentik | provider UUID | issuer URL |
61
+ | Auth0 | provider user id | issuer URL |
62
+ | Firebase | provider uid | issuer URL |
63
+ | Custom OIDC | JWT `sub` | JWT `iss` |
64
+
65
+ That gives you a stable application-owned identity layer even if you later replace the auth provider.
@@ -0,0 +1,147 @@
1
+ -- ============================================================================
2
+ -- 001_create_app_users.sql
3
+ -- Vendor-independent user registry for projects using @edcalderon/auth.
4
+ --
5
+ -- This creates an application-owned public.users table keyed by (sub, iss)
6
+ -- and a secure upsert RPC intended for trusted server-side use.
7
+ -- ============================================================================
8
+
9
+ create extension if not exists pgcrypto;
10
+
11
+ create table if not exists public.users (
12
+ id uuid primary key default gen_random_uuid(),
13
+ sub text not null,
14
+ iss text not null,
15
+ email text,
16
+ email_verified boolean not null default false,
17
+ name text,
18
+ picture text,
19
+ provider text,
20
+ raw_claims jsonb not null default '{}'::jsonb,
21
+ created_at timestamptz not null default timezone('utc', now()),
22
+ updated_at timestamptz not null default timezone('utc', now()),
23
+
24
+ constraint users_sub_iss_uq unique (sub, iss),
25
+ constraint users_sub_len_chk check (char_length(sub) <= 256),
26
+ constraint users_iss_len_chk check (char_length(iss) <= 512),
27
+ constraint users_email_len_chk check (
28
+ email is null or char_length(email) <= 320
29
+ )
30
+ );
31
+
32
+ create index if not exists users_email_idx
33
+ on public.users (lower(email))
34
+ where email is not null;
35
+
36
+ create index if not exists users_provider_idx
37
+ on public.users (provider)
38
+ where provider is not null;
39
+
40
+ create or replace function public.users_set_updated_at()
41
+ returns trigger
42
+ language plpgsql
43
+ as $$
44
+ begin
45
+ new.updated_at = timezone('utc', now());
46
+ return new;
47
+ end;
48
+ $$;
49
+
50
+ drop trigger if exists trg_users_set_updated_at on public.users;
51
+
52
+ create trigger trg_users_set_updated_at
53
+ before update on public.users
54
+ for each row
55
+ execute function public.users_set_updated_at();
56
+
57
+ create or replace function public.upsert_oidc_user(
58
+ p_sub text,
59
+ p_iss text,
60
+ p_email text default null,
61
+ p_email_verified boolean default false,
62
+ p_name text default null,
63
+ p_picture text default null,
64
+ p_provider text default null,
65
+ p_raw_claims jsonb default '{}'::jsonb
66
+ )
67
+ returns public.users
68
+ language plpgsql
69
+ security definer
70
+ set search_path = public
71
+ as $$
72
+ declare
73
+ v_claims jsonb := coalesce(
74
+ nullif(current_setting('request.jwt.claims', true), ''),
75
+ '{}'
76
+ )::jsonb;
77
+ v_role text := coalesce(nullif(v_claims ->> 'role', ''), 'unknown');
78
+ v_user public.users;
79
+ begin
80
+ if v_role <> 'service_role' then
81
+ raise exception 'upsert_oidc_user() requires a trusted server-side caller';
82
+ end if;
83
+
84
+ if nullif(trim(p_sub), '') is null or nullif(trim(p_iss), '') is null then
85
+ raise exception 'upsert_oidc_user() requires non-empty p_sub and p_iss';
86
+ end if;
87
+
88
+ insert into public.users (
89
+ sub,
90
+ iss,
91
+ email,
92
+ email_verified,
93
+ name,
94
+ picture,
95
+ provider,
96
+ raw_claims
97
+ )
98
+ values (
99
+ trim(p_sub),
100
+ trim(p_iss),
101
+ nullif(trim(p_email), ''),
102
+ p_email_verified,
103
+ nullif(trim(p_name), ''),
104
+ nullif(trim(p_picture), ''),
105
+ nullif(trim(p_provider), ''),
106
+ coalesce(p_raw_claims, '{}'::jsonb)
107
+ )
108
+ on conflict (sub, iss) do update
109
+ set email = coalesce(excluded.email, public.users.email),
110
+ email_verified = public.users.email_verified or excluded.email_verified,
111
+ name = coalesce(excluded.name, public.users.name),
112
+ picture = coalesce(excluded.picture, public.users.picture),
113
+ provider = coalesce(excluded.provider, public.users.provider),
114
+ raw_claims = case
115
+ when excluded.raw_claims = '{}'::jsonb then public.users.raw_claims
116
+ else public.users.raw_claims || excluded.raw_claims
117
+ end,
118
+ updated_at = timezone('utc', now())
119
+ returning * into v_user;
120
+
121
+ return v_user;
122
+ end;
123
+ $$;
124
+
125
+ revoke all on function public.upsert_oidc_user(
126
+ text,
127
+ text,
128
+ text,
129
+ boolean,
130
+ text,
131
+ text,
132
+ text,
133
+ jsonb
134
+ ) from public, anon, authenticated;
135
+
136
+ grant execute on function public.upsert_oidc_user(
137
+ text,
138
+ text,
139
+ text,
140
+ boolean,
141
+ text,
142
+ text,
143
+ text,
144
+ jsonb
145
+ ) to service_role;
146
+
147
+ alter table public.users enable row level security;
@@ -0,0 +1,101 @@
1
+ -- ============================================================================
2
+ -- 002_sync_auth_users_to_app_users.sql
3
+ -- Optional trigger for projects that use Supabase Auth.
4
+ -- Keeps auth.users mirrored into public.users and backfills existing users.
5
+ -- ============================================================================
6
+
7
+ create or replace function public.sync_auth_user_to_app_users()
8
+ returns trigger
9
+ language plpgsql
10
+ security definer
11
+ set search_path = public
12
+ as $$
13
+ begin
14
+ if new.is_anonymous then
15
+ return new;
16
+ end if;
17
+
18
+ insert into public.users (
19
+ sub,
20
+ iss,
21
+ email,
22
+ email_verified,
23
+ name,
24
+ picture,
25
+ provider,
26
+ raw_claims
27
+ )
28
+ values (
29
+ new.id::text,
30
+ 'supabase',
31
+ new.email,
32
+ new.email_confirmed_at is not null,
33
+ coalesce(
34
+ new.raw_user_meta_data ->> 'name',
35
+ new.raw_user_meta_data ->> 'full_name',
36
+ split_part(coalesce(new.email, ''), '@', 1)
37
+ ),
38
+ new.raw_user_meta_data ->> 'avatar_url',
39
+ coalesce(new.raw_app_meta_data ->> 'provider', 'email'),
40
+ coalesce(new.raw_user_meta_data, '{}'::jsonb)
41
+ )
42
+ on conflict (sub, iss) do update
43
+ set email = coalesce(excluded.email, public.users.email),
44
+ email_verified = public.users.email_verified or excluded.email_verified,
45
+ name = coalesce(excluded.name, public.users.name),
46
+ picture = coalesce(excluded.picture, public.users.picture),
47
+ provider = coalesce(excluded.provider, public.users.provider),
48
+ raw_claims = case
49
+ when excluded.raw_claims = '{}'::jsonb then public.users.raw_claims
50
+ else public.users.raw_claims || excluded.raw_claims
51
+ end,
52
+ updated_at = timezone('utc', now());
53
+
54
+ return new;
55
+ end;
56
+ $$;
57
+
58
+ drop trigger if exists trg_auth_users_sync_to_app_users on auth.users;
59
+
60
+ create trigger trg_auth_users_sync_to_app_users
61
+ after insert or update of email_confirmed_at, email, raw_user_meta_data, raw_app_meta_data
62
+ on auth.users
63
+ for each row
64
+ execute function public.sync_auth_user_to_app_users();
65
+
66
+ insert into public.users (
67
+ sub,
68
+ iss,
69
+ email,
70
+ email_verified,
71
+ name,
72
+ picture,
73
+ provider,
74
+ raw_claims
75
+ )
76
+ select
77
+ u.id::text,
78
+ 'supabase',
79
+ u.email,
80
+ u.email_confirmed_at is not null,
81
+ coalesce(
82
+ u.raw_user_meta_data ->> 'name',
83
+ u.raw_user_meta_data ->> 'full_name',
84
+ split_part(coalesce(u.email, ''), '@', 1)
85
+ ),
86
+ u.raw_user_meta_data ->> 'avatar_url',
87
+ coalesce(u.raw_app_meta_data ->> 'provider', 'email'),
88
+ coalesce(u.raw_user_meta_data, '{}'::jsonb)
89
+ from auth.users u
90
+ where not u.is_anonymous
91
+ on conflict (sub, iss) do update
92
+ set email = coalesce(excluded.email, public.users.email),
93
+ email_verified = public.users.email_verified or excluded.email_verified,
94
+ name = coalesce(excluded.name, public.users.name),
95
+ picture = coalesce(excluded.picture, public.users.picture),
96
+ provider = coalesce(excluded.provider, public.users.provider),
97
+ raw_claims = case
98
+ when excluded.raw_claims = '{}'::jsonb then public.users.raw_claims
99
+ else public.users.raw_claims || excluded.raw_claims
100
+ end,
101
+ updated_at = timezone('utc', now());