Skip to content

Commit fdd68e7

Browse files
committed
feat: apikey parse
1 parent 19231c2 commit fdd68e7

3 files changed

Lines changed: 286 additions & 0 deletions

File tree

mod.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { IPAddress, IPAddressEntity } from "./src/IPAddress.ts";
2828
import { SSN, SSNEntity } from "./src/SSN.ts";
2929
import { CreditCard, CreditCardEntity } from "./src/CreditCard.ts";
3030
import { UUID, UUIDEntity } from "./src/UUID.ts";
31+
import { ApiKey } from "./src/ApiKey.ts";
3132

3233
export type AnyEntity =
3334
| TemperatureEntity
@@ -62,6 +63,7 @@ export const Duckling = (
6263
Location.parser,
6364
Institution.parser,
6465
Language.parser,
66+
ApiKey.parser,
6567
],
6668
) =>
6769
createLanguageThis({
@@ -117,3 +119,4 @@ export * from "./src/CreditCard.ts";
117119
export * from "./src/UUID.ts";
118120
export * from "./src/Institution.ts";
119121
export * from "./src/Language.ts";
122+
export * from "./src/ApiKey.ts";

src/ApiKey.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import {
2+
any,
3+
Context,
4+
createLanguageThis,
5+
map,
6+
regex,
7+
seq,
8+
} from "@claudiu-ceia/combine";
9+
import type { Parser } from "@claudiu-ceia/combine";
10+
import { dot } from "./common.ts";
11+
import { ent, Entity } from "./Entity.ts";
12+
13+
export type ApiKeyEntity = Entity<
14+
"api_key",
15+
{
16+
provider?: string;
17+
key: string;
18+
}
19+
>;
20+
21+
type ApiKeyLanguage = {
22+
Prefix: () => Parser<string>;
23+
PrefixKey: () => Parser<ApiKeyEntity["value"]>;
24+
Full: () => Parser<ApiKeyEntity>;
25+
parser: () => Parser<ApiKeyEntity>;
26+
};
27+
28+
const ProviderPrefixes: Record<string, string> = {
29+
// Stripe
30+
"sk_live_": "stripe",
31+
"sk_test_": "stripe",
32+
"pk_live_": "stripe",
33+
"pk_test_": "stripe",
34+
"rk_live_": "stripe",
35+
"rk_test_": "stripe",
36+
37+
// OpenAI
38+
"sk-": "openai",
39+
"sk-proj-": "openai",
40+
"sk-svcacct-": "openai",
41+
42+
// Anthropic
43+
"sk-ant-": "anthropic",
44+
"sk-ant-api03-": "anthropic",
45+
46+
// GitHub
47+
"ghp_": "github",
48+
"gho_": "github",
49+
"ghu_": "github",
50+
"ghs_": "github",
51+
"ghr_": "github",
52+
"github_pat_": "github",
53+
54+
// GitLab
55+
"glpat-": "gitlab",
56+
57+
// Slack
58+
"xoxb-": "slack",
59+
"xoxp-": "slack",
60+
"xoxa-": "slack",
61+
"xoxr-": "slack",
62+
"xapp-": "slack",
63+
64+
// AWS access key IDs (public identifier, not the secret)
65+
"AKIA": "aws",
66+
"ASIA": "aws",
67+
"AIDA": "aws",
68+
"AGPA": "aws",
69+
"ANPA": "aws",
70+
"ANVA": "aws",
71+
"AROA": "aws",
72+
"AIPA": "aws",
73+
74+
// Google API keys
75+
"AIza": "google",
76+
77+
// SendGrid
78+
"SG.": "sendgrid",
79+
80+
// Mailgun
81+
"key-": "mailgun",
82+
83+
// Mapbox
84+
"pk.": "mapbox",
85+
"sk.": "mapbox",
86+
};
87+
88+
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
89+
90+
const ProviderPrefixRegex = new RegExp(
91+
Object.keys(ProviderPrefixes)
92+
// Prefer longer prefixes first (e.g. "sk-ant-api03-" before "sk-")
93+
.sort((a, b) => b.length - a.length)
94+
.map(escapeRegex)
95+
.join("|"),
96+
);
97+
98+
export const apiKey = (
99+
value: ApiKeyEntity["value"],
100+
before: Context,
101+
after: Context,
102+
): ApiKeyEntity => {
103+
return ent(value, "api_key", before, after);
104+
};
105+
106+
export const ApiKey = createLanguageThis<ApiKeyLanguage>({
107+
/**
108+
* Matches prefix for common API key formats, e.g. "sk-" for Stripe, "pk-" for some others, etc.
109+
* This is optional since not all API keys have a prefix.
110+
*
111+
* Returns a provider name if a known prefix is matched, or undefined otherwise.
112+
* This allows downstream code to potentially apply provider-specific validation or parsing logic.
113+
*/
114+
Prefix(): Parser<string> {
115+
return map(
116+
regex(ProviderPrefixRegex, "api-key-prefix"),
117+
(prefix) => ProviderPrefixes[prefix],
118+
);
119+
},
120+
/**
121+
* Parses a known provider prefix + key body.
122+
*/
123+
PrefixKey(): Parser<ApiKeyEntity["value"]> {
124+
return map(
125+
seq(
126+
regex(ProviderPrefixRegex, "api-key-prefix"),
127+
regex(/[A-Za-z0-9][A-Za-z0-9._-]{7,199}/, "api-key-body"),
128+
),
129+
([prefix, body]) => ({
130+
provider: ProviderPrefixes[prefix],
131+
key: `${prefix}${body}`,
132+
}),
133+
);
134+
},
135+
Full(): Parser<ApiKeyEntity> {
136+
return map(this.PrefixKey, (value, b, a) => apiKey(value, b, a));
137+
},
138+
parser(): Parser<ApiKeyEntity> {
139+
return dot(any(this.Full));
140+
},
141+
});

tests/ApiKey.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { assertEquals } from "@std/assert";
2+
import { ApiKey } from "../src/ApiKey.ts";
3+
4+
const parseAtToken = (text: string, token: string) => {
5+
const index = text.indexOf(token);
6+
if (index === -1) {
7+
throw new Error(`Token not found in text: ${token}`);
8+
}
9+
return ApiKey.parser({ text, index });
10+
};
11+
12+
Deno.test("ApiKey: Stripe sk_live_", () => {
13+
const token = `sk_live_${"a".repeat(24)}`;
14+
const text = `use ${token} please`;
15+
const res = parseAtToken(text, token);
16+
17+
assertEquals(res.success, true);
18+
if (res.success) {
19+
assertEquals(res.value.kind, "api_key");
20+
assertEquals(res.value.text, token);
21+
assertEquals(res.value.value.provider, "stripe");
22+
assertEquals(res.value.value.key, token);
23+
}
24+
});
25+
26+
Deno.test("ApiKey: OpenAI sk-proj- wins over sk-", () => {
27+
const token = `sk-proj-${"b".repeat(24)}`;
28+
const text = `hello ${token} world`;
29+
const res = parseAtToken(text, token);
30+
31+
assertEquals(res.success, true);
32+
if (res.success) {
33+
assertEquals(res.value.value.provider, "openai");
34+
assertEquals(res.value.value.key, token);
35+
assertEquals(res.value.text.startsWith("sk-proj-"), true);
36+
}
37+
});
38+
39+
Deno.test("ApiKey: Anthropic sk-ant-api03-", () => {
40+
const token = `sk-ant-api03-${"c".repeat(24)}`;
41+
const text = `${token}`; // EOF boundary should be accepted.
42+
const res = parseAtToken(text, token);
43+
44+
assertEquals(res.success, true);
45+
if (res.success) {
46+
assertEquals(res.value.value.provider, "anthropic");
47+
assertEquals(res.value.value.key, token);
48+
}
49+
});
50+
51+
Deno.test("ApiKey: GitHub ghp_", () => {
52+
const token = `ghp_${"d".repeat(32)}`;
53+
const text = `token=${token};`;
54+
const res = parseAtToken(text, token);
55+
56+
assertEquals(res.success, true);
57+
if (res.success) {
58+
assertEquals(res.value.value.provider, "github");
59+
assertEquals(res.value.value.key, token);
60+
}
61+
});
62+
63+
Deno.test("ApiKey: GitLab glpat-", () => {
64+
const token = `glpat-${"e".repeat(24)}`;
65+
const text = `Bearer ${token} `;
66+
const res = parseAtToken(text, token);
67+
68+
assertEquals(res.success, true);
69+
if (res.success) {
70+
assertEquals(res.value.value.provider, "gitlab");
71+
assertEquals(res.value.value.key, token);
72+
}
73+
});
74+
75+
Deno.test("ApiKey: Slack xoxb-", () => {
76+
const token = `xoxb-${"f".repeat(30)}`;
77+
const text = `slack=${token}\n`;
78+
const res = parseAtToken(text, token);
79+
80+
assertEquals(res.success, true);
81+
if (res.success) {
82+
assertEquals(res.value.value.provider, "slack");
83+
assertEquals(res.value.value.key, token);
84+
}
85+
});
86+
87+
Deno.test("ApiKey: AWS access key id AKIA...", () => {
88+
const token = `AKIA${"G".repeat(16)}`;
89+
const text = `aws ${token} ok`;
90+
const res = parseAtToken(text, token);
91+
92+
assertEquals(res.success, true);
93+
if (res.success) {
94+
assertEquals(res.value.value.provider, "aws");
95+
assertEquals(res.value.value.key, token);
96+
}
97+
});
98+
99+
Deno.test("ApiKey: Google API key AIza...", () => {
100+
const token = `AIza${"h".repeat(32)}`;
101+
const text = `key: ${token} `;
102+
const res = parseAtToken(text, token);
103+
104+
assertEquals(res.success, true);
105+
if (res.success) {
106+
assertEquals(res.value.value.provider, "google");
107+
assertEquals(res.value.value.key, token);
108+
}
109+
});
110+
111+
Deno.test("ApiKey: SendGrid SG. token", () => {
112+
const token = `SG.${"i".repeat(10)}.${"j".repeat(20)}`;
113+
const text = `sendgrid=${token} `;
114+
const res = parseAtToken(text, token);
115+
116+
assertEquals(res.success, true);
117+
if (res.success) {
118+
assertEquals(res.value.value.provider, "sendgrid");
119+
assertEquals(res.value.value.key, token);
120+
}
121+
});
122+
123+
Deno.test("ApiKey: does not match too-short body", () => {
124+
const token = "ghp_1234567"; // 7 chars body, min is 8
125+
const text = `${token} `;
126+
const res = parseAtToken(text, token);
127+
128+
assertEquals(res.success, false);
129+
});
130+
131+
Deno.test("ApiKey: does not include trailing punctuation", () => {
132+
const token = `sk_test_${"k".repeat(16)}`;
133+
const text = `${token};`; // ';' is not part of the body; should stop before it.
134+
const res = parseAtToken(text, token);
135+
136+
assertEquals(res.success, true);
137+
if (res.success) {
138+
assertEquals(res.value.text, token);
139+
assertEquals(res.value.value.provider, "stripe");
140+
assertEquals(res.value.value.key, token);
141+
}
142+
});

0 commit comments

Comments
 (0)