authdrift 0.1.0__tar.gz

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.
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .semgrep/
10
+ .semgrep_logs/
11
+ .DS_Store
12
+ *.swp
13
+ fp-validation/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Srinathprasanna Neelagiri Chettiyar Shanmugam
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: authdrift
3
+ Version: 0.1.0
4
+ Summary: Find OAuth handlers that will break when users rename their identifier.
5
+ Project-URL: Homepage, https://github.com/Neelagiri65/authdrift
6
+ Project-URL: Issues, https://github.com/Neelagiri65/authdrift/issues
7
+ Author: Neelagiri
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: gmail,identity,oauth,oidc,phantom-user,sast,security,semgrep
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: Security
18
+ Classifier: Topic :: Software Development :: Quality Assurance
19
+ Requires-Python: >=3.9
20
+ Requires-Dist: semgrep>=1.50.0
21
+ Description-Content-Type: text/markdown
22
+
23
+ # authdrift
24
+
25
+ > Find OAuth handlers that will break when users rename their Gmail.
26
+
27
+ On 31 March 2026, Google began letting users rename their primary Gmail address. Every OAuth integration that uses email as the user lookup key will silently create a duplicate account when a user renames. Most do.
28
+
29
+ `authdrift` is a static analysis tool that scans your codebase for the exact patterns that explode on a Gmail rename — and on the ~0.04% baseline `sub`-claim drift Truffle Security documented in January 2025.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pip install authdrift
35
+ ```
36
+
37
+ Requires `semgrep` (installed automatically).
38
+
39
+ ## Use
40
+
41
+ ```bash
42
+ authdrift scan ./
43
+ ```
44
+
45
+ Example output:
46
+
47
+ ```
48
+ src/auth/google.ts
49
+ 12:18 WARNING oauth-passport-email-as-primary-key
50
+ This OAuth handler is using `profile.emails[0].value` as a user lookup key.
51
+ When a user renames their Gmail address, this lookup will fail and your
52
+ application will silently create a duplicate user record.
53
+ Fix: use `profile.id` (the OIDC `sub` claim) as the immutable primary key.
54
+ ```
55
+
56
+ JSON output for CI:
57
+
58
+ ```bash
59
+ authdrift scan ./ --json
60
+ ```
61
+
62
+ ## Why this matters
63
+
64
+ Atlassian's own KB confirms that SCIM email changes create duplicate Atlassian Cloud accounts. Google's developer documentation acknowledges that revoke + reauth produces duplicate accounts. The cascade is documented; the fix is not deployed.
65
+
66
+ `authdrift` flags the exact code patterns that produce phantom users after a rename event. Three rules, narrowly scoped, designed to slot into CI alongside `semgrep`, `trufflehog`, and `gitleaks` without producing noise.
67
+
68
+ ## What it catches today
69
+
70
+ - **Passport.js** handlers using `profile.emails[0].value` as a user lookup key (`passport-google-oauth20`, `passport-google-oauth2`)
71
+ - **NextAuth** `signIn` callbacks resolving users by `user.email` against Prisma
72
+ - **Python** (Django / SQLAlchemy) handlers querying by `userinfo['email']` from Google's userinfo response
73
+
74
+ ## What it deliberately does NOT do
75
+
76
+ - Flag every reference to `email` in an OAuth file. The rules require the email value to be used as a lookup key, not just read or logged.
77
+ - Flag handlers that key on `sub` / `profile.id` and use email only as a contact attribute.
78
+ - Auto-fix anything. Read-only by design.
79
+
80
+ ## Roadmap
81
+
82
+ - More OAuth library coverage (lucia-auth, authlib, omniauth, Clerk, Supabase Auth, Firebase Auth)
83
+ - A `--fix` mode that suggests the `sub`-keyed equivalent
84
+ - A hosted scan for organisations that can't run a CLI
85
+
86
+ ## License
87
+
88
+ MIT.
@@ -0,0 +1,66 @@
1
+ # authdrift
2
+
3
+ > Find OAuth handlers that will break when users rename their Gmail.
4
+
5
+ On 31 March 2026, Google began letting users rename their primary Gmail address. Every OAuth integration that uses email as the user lookup key will silently create a duplicate account when a user renames. Most do.
6
+
7
+ `authdrift` is a static analysis tool that scans your codebase for the exact patterns that explode on a Gmail rename — and on the ~0.04% baseline `sub`-claim drift Truffle Security documented in January 2025.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install authdrift
13
+ ```
14
+
15
+ Requires `semgrep` (installed automatically).
16
+
17
+ ## Use
18
+
19
+ ```bash
20
+ authdrift scan ./
21
+ ```
22
+
23
+ Example output:
24
+
25
+ ```
26
+ src/auth/google.ts
27
+ 12:18 WARNING oauth-passport-email-as-primary-key
28
+ This OAuth handler is using `profile.emails[0].value` as a user lookup key.
29
+ When a user renames their Gmail address, this lookup will fail and your
30
+ application will silently create a duplicate user record.
31
+ Fix: use `profile.id` (the OIDC `sub` claim) as the immutable primary key.
32
+ ```
33
+
34
+ JSON output for CI:
35
+
36
+ ```bash
37
+ authdrift scan ./ --json
38
+ ```
39
+
40
+ ## Why this matters
41
+
42
+ Atlassian's own KB confirms that SCIM email changes create duplicate Atlassian Cloud accounts. Google's developer documentation acknowledges that revoke + reauth produces duplicate accounts. The cascade is documented; the fix is not deployed.
43
+
44
+ `authdrift` flags the exact code patterns that produce phantom users after a rename event. Three rules, narrowly scoped, designed to slot into CI alongside `semgrep`, `trufflehog`, and `gitleaks` without producing noise.
45
+
46
+ ## What it catches today
47
+
48
+ - **Passport.js** handlers using `profile.emails[0].value` as a user lookup key (`passport-google-oauth20`, `passport-google-oauth2`)
49
+ - **NextAuth** `signIn` callbacks resolving users by `user.email` against Prisma
50
+ - **Python** (Django / SQLAlchemy) handlers querying by `userinfo['email']` from Google's userinfo response
51
+
52
+ ## What it deliberately does NOT do
53
+
54
+ - Flag every reference to `email` in an OAuth file. The rules require the email value to be used as a lookup key, not just read or logged.
55
+ - Flag handlers that key on `sub` / `profile.id` and use email only as a contact attribute.
56
+ - Auto-fix anything. Read-only by design.
57
+
58
+ ## Roadmap
59
+
60
+ - More OAuth library coverage (lucia-auth, authlib, omniauth, Clerk, Supabase Auth, Firebase Auth)
61
+ - A `--fix` mode that suggests the `sub`-keyed equivalent
62
+ - A hosted scan for organisations that can't run a CLI
63
+
64
+ ## License
65
+
66
+ MIT.
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "authdrift"
7
+ version = "0.1.0"
8
+ description = "Find OAuth handlers that will break when users rename their identifier."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Neelagiri" }]
13
+ keywords = [
14
+ "oauth",
15
+ "oidc",
16
+ "security",
17
+ "sast",
18
+ "semgrep",
19
+ "identity",
20
+ "gmail",
21
+ "phantom-user",
22
+ ]
23
+ classifiers = [
24
+ "Development Status :: 3 - Alpha",
25
+ "Intended Audience :: Developers",
26
+ "Intended Audience :: System Administrators",
27
+ "Topic :: Security",
28
+ "Topic :: Software Development :: Quality Assurance",
29
+ "License :: OSI Approved :: MIT License",
30
+ "Programming Language :: Python :: 3",
31
+ "Programming Language :: Python :: 3 :: Only",
32
+ ]
33
+ dependencies = [
34
+ "semgrep>=1.50.0",
35
+ ]
36
+
37
+ [project.scripts]
38
+ authdrift = "authdrift.cli:main"
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/Neelagiri65/authdrift"
42
+ Issues = "https://github.com/Neelagiri65/authdrift/issues"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/authdrift"]
46
+
47
+ [tool.hatch.build.targets.sdist]
48
+ include = [
49
+ "src/authdrift",
50
+ "README.md",
51
+ "LICENSE",
52
+ "tests",
53
+ ]
@@ -0,0 +1,3 @@
1
+ """authdrift — find OAuth handlers that break on identifier rename."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,89 @@
1
+ """authdrift CLI — wraps semgrep with the bundled authdrift ruleset."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from importlib.resources import as_file, files
11
+
12
+ from . import __version__
13
+
14
+
15
+ def _rules_path() -> str:
16
+ """Return the on-disk path to the bundled rules directory."""
17
+ rules = files("authdrift").joinpath("rules")
18
+ with as_file(rules) as p:
19
+ return str(p)
20
+
21
+
22
+ def _find_semgrep() -> str | None:
23
+ """Locate the semgrep executable.
24
+
25
+ Prefer the binary adjacent to the current Python interpreter so that
26
+ a venv-installed authdrift uses its venv-installed semgrep — even when
27
+ that venv is not active on PATH.
28
+ """
29
+ bin_dir = os.path.dirname(sys.executable)
30
+ candidate = os.path.join(bin_dir, "semgrep")
31
+ if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
32
+ return candidate
33
+ return shutil.which("semgrep")
34
+
35
+
36
+ def _scan(path: str, json_output: bool) -> int:
37
+ semgrep_bin = _find_semgrep()
38
+ if semgrep_bin is None:
39
+ sys.stderr.write(
40
+ "authdrift: semgrep is required but was not found.\n"
41
+ "Install it with: pip install semgrep\n"
42
+ )
43
+ return 2
44
+
45
+ cmd = [semgrep_bin, "scan", "--config", _rules_path(), "--error", path]
46
+ if json_output:
47
+ cmd.append("--json")
48
+ return subprocess.call(cmd)
49
+
50
+
51
+ def main(argv: list[str] | None = None) -> int:
52
+ parser = argparse.ArgumentParser(
53
+ prog="authdrift",
54
+ description=(
55
+ "Find OAuth handlers that will break when users rename their "
56
+ "identifier (e.g. a Gmail rename)."
57
+ ),
58
+ )
59
+ parser.add_argument(
60
+ "--version",
61
+ action="version",
62
+ version=f"authdrift {__version__}",
63
+ )
64
+ sub = parser.add_subparsers(dest="command", required=True)
65
+
66
+ scan = sub.add_parser("scan", help="Scan a directory for authdrift patterns.")
67
+ scan.add_argument(
68
+ "path",
69
+ nargs="?",
70
+ default=".",
71
+ help="Path to scan (default: current directory).",
72
+ )
73
+ scan.add_argument(
74
+ "--json",
75
+ action="store_true",
76
+ help="Emit semgrep JSON output instead of human-readable.",
77
+ )
78
+
79
+ args = parser.parse_args(argv)
80
+
81
+ if args.command == "scan":
82
+ return _scan(args.path, args.json)
83
+
84
+ parser.print_help()
85
+ return 1
86
+
87
+
88
+ if __name__ == "__main__":
89
+ sys.exit(main())
@@ -0,0 +1,73 @@
1
+ rules:
2
+ - id: oauth-passport-email-as-primary-key
3
+ message: |
4
+ This OAuth handler is using `profile.emails[0].value` as a user lookup key.
5
+
6
+ When a user renames their Gmail address (Google rolled out Gmail rename in
7
+ March 2026), the email returned by Google changes and your application will
8
+ silently create a duplicate user record — fragmenting their identity, audit
9
+ history, and ACLs. The same drift occurs in ~0.04% of normal Sign-in-with-Google
10
+ logins (Truffle Security, January 2025), independent of the rename feature.
11
+
12
+ Fix: use `profile.id` (the OIDC `sub` claim) as the immutable primary key.
13
+ Treat `profile.emails[0].value` as a mutable contact attribute, not an
14
+ identifier.
15
+
16
+ See: https://github.com/Neelagiri65/authdrift#why-this-matters
17
+ severity: WARNING
18
+ languages:
19
+ - javascript
20
+ - typescript
21
+ patterns:
22
+ - pattern-either:
23
+ - pattern: |
24
+ $FN({..., email: $P.emails[0].value, ...})
25
+ - pattern: |
26
+ $FN({..., where: {email: $P.emails[0].value}})
27
+ - pattern: |
28
+ $FN({..., where: {email: $P.emails[0].value, ...}})
29
+
30
+ - id: nextauth-signin-callback-email-as-primary-key
31
+ message: |
32
+ This NextAuth `signIn` flow is resolving users by `email`. When a Google user
33
+ renames their Gmail address (March 2026 rollout), the email returned by the
34
+ provider changes and a duplicate user record will be created on next sign-in.
35
+
36
+ Fix: key the lookup on `account.providerAccountId` (the OIDC `sub` claim) and
37
+ treat email as a mutable contact attribute.
38
+
39
+ See: https://github.com/Neelagiri65/authdrift#why-this-matters
40
+ severity: WARNING
41
+ languages:
42
+ - javascript
43
+ - typescript
44
+ patterns:
45
+ - pattern-either:
46
+ - pattern: |
47
+ prisma.user.findUnique({where: {email: $U.email}})
48
+ - pattern: |
49
+ prisma.user.findFirst({where: {email: $U.email}})
50
+ - pattern: |
51
+ db.user.findUnique({where: {email: $U.email}})
52
+
53
+ - id: python-google-oauth-userinfo-email-as-primary-key
54
+ message: |
55
+ This handler queries a user by the `email` field returned from Google's OAuth
56
+ userinfo response. When the user renames their Gmail address (March 2026
57
+ rollout), the email field changes and a duplicate account will be created.
58
+
59
+ Fix: store and look up by `userinfo['sub']` (the immutable subject ID).
60
+ Treat email as a mutable contact attribute.
61
+
62
+ See: https://github.com/Neelagiri65/authdrift#why-this-matters
63
+ severity: WARNING
64
+ languages:
65
+ - python
66
+ patterns:
67
+ - pattern-either:
68
+ - pattern: |
69
+ $MODEL.objects.get(email=$USERINFO['email'])
70
+ - pattern: |
71
+ $MODEL.objects.filter(email=$USERINFO['email']).first()
72
+ - pattern: |
73
+ $MODEL.query.filter_by(email=$USERINFO['email']).first()
@@ -0,0 +1,24 @@
1
+ // Should trigger nextauth-signin-callback-email-as-primary-key
2
+ import NextAuth from 'next-auth';
3
+ import GoogleProvider from 'next-auth/providers/google';
4
+ import { prisma } from '@/lib/prisma';
5
+
6
+ export default NextAuth({
7
+ providers: [
8
+ GoogleProvider({
9
+ clientId: process.env.GOOGLE_CLIENT_ID!,
10
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
11
+ }),
12
+ ],
13
+ callbacks: {
14
+ async signIn({ user, account }) {
15
+ const existing = await prisma.user.findUnique({ where: { email: user.email } });
16
+ if (!existing) {
17
+ await prisma.user.create({
18
+ data: { email: user.email, name: user.name },
19
+ });
20
+ }
21
+ return true;
22
+ },
23
+ },
24
+ });
@@ -0,0 +1,20 @@
1
+ // Should NOT trigger — keys on profile.id (the OIDC sub claim)
2
+ const passport = require('passport');
3
+ const GoogleStrategy = require('passport-google-oauth20').Strategy;
4
+
5
+ passport.use(new GoogleStrategy({
6
+ clientID: process.env.GOOGLE_CLIENT_ID,
7
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
8
+ callbackURL: '/auth/google/callback',
9
+ }, async (accessToken, refreshToken, profile, done) => {
10
+ // Email is recorded as a contact attribute, but the lookup key is the immutable sub.
11
+ const user = await db.findUser({ googleId: profile.id });
12
+ if (!user) {
13
+ return db.createUser({
14
+ googleId: profile.id,
15
+ email: profile.emails[0].value,
16
+ name: profile.displayName,
17
+ }, done);
18
+ }
19
+ return done(null, user);
20
+ }));
@@ -0,0 +1,15 @@
1
+ // Should trigger oauth-passport-email-as-primary-key
2
+ const passport = require('passport');
3
+ const GoogleStrategy = require('passport-google-oauth20').Strategy;
4
+
5
+ passport.use(new GoogleStrategy({
6
+ clientID: process.env.GOOGLE_CLIENT_ID,
7
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
8
+ callbackURL: '/auth/google/callback',
9
+ }, async (accessToken, refreshToken, profile, done) => {
10
+ const user = await db.findUser({ email: profile.emails[0].value });
11
+ if (!user) {
12
+ return db.createUser({ email: profile.emails[0].value, name: profile.displayName }, done);
13
+ }
14
+ return done(null, user);
15
+ }));
@@ -0,0 +1,25 @@
1
+ # Should trigger python-google-oauth-userinfo-email-as-primary-key
2
+ import requests
3
+ from django.contrib.auth.models import User
4
+
5
+
6
+ def google_oauth_callback(request):
7
+ code = request.GET.get("code")
8
+ token_resp = requests.post(
9
+ "https://oauth2.googleapis.com/token",
10
+ data={
11
+ "code": code,
12
+ "client_id": "...",
13
+ "client_secret": "...",
14
+ "redirect_uri": "...",
15
+ "grant_type": "authorization_code",
16
+ },
17
+ )
18
+ access_token = token_resp.json()["access_token"]
19
+ userinfo = requests.get(
20
+ "https://www.googleapis.com/oauth2/v3/userinfo",
21
+ headers={"Authorization": f"Bearer {access_token}"},
22
+ ).json()
23
+
24
+ user = User.objects.get(email=userinfo["email"])
25
+ return user