OAuth 2.0 認証サーバーを作る(Client Credentials Grant だけ)

SQLite をデータベースに使用し、アプリケーションを作ってみたくなった(単純にお安く済みそうだから)。
SQLite は、ネットワーク機能は持っていないので、そこは API サーバーとしてフォローする必要があった。
認証機能として、OAuth 2.0 Client Credentials Grant を実現する簡単なアプリケーションを書いたメモ。

参考

実装

ディレクトリの中身は次の通り。

1
2
3
4
5
6
7
8
9
.
|-- .env
|-- app.ts
|-- clients.ts
|-- config.ts
|-- deps.ts
|-- orm.ts
|-- test.db
`-- validates.ts

ソースコードは次の通り。

./.env
1
2
BASIC_SECRET = <文字列>
SALT = <文字列>
./app.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { Application, Router } from "./deps.ts";
import { orm, Token } from "./orm.ts";
import {
accessResourceRequestCheck,
accessTokenRequestCheck,
checkBasicAuth,
} from "./validates.ts";

const router = new Router();
router.get("/resource/users", (context) => {
const result = accessResourceRequestCheck(context.request);
if (!result.status) return;

// クライアントごとのアクセス制限のチェックもありそうだが今回はskip

context.response.body = {
users: [
{ id: 1, name: "A" },
{ id: 2, name: "B" },
],
};
});
router.post("/oauth/token", async (context) => {
if (!checkBasicAuth(context.request)) return;
const result = await accessTokenRequestCheck(context.request);
if (!result.status) return;

const token = new Token();
token.clientId = result.clientId;
token.token = crypto.randomUUID();
orm.save(token);

context.response.body = {
access_token: token.token,
token_type: "bearer",
expires_in: 3600,
};
});

const app = new Application();

app.use(router.routes());
app.use(router.allowedMethods());

await app.listen({ port: 8080 });
./clients.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { createHash } from "./deps.ts";
import { config } from "./config.ts";

// サンプルなのでベタ打ちするが、本来はDBなりでhash化したものを補完しておくべき
const clientSecret = "qwertyuiopasdfghjklzxcvbnm"

const hash = createHash("sha256");
hash.update(`${config.salt}-${clientSecret}-${config.salt}`);

interface Clients {
[key: string]: { secret: string };
}

export const clients = {
"1": { secret: hash.toString() },
} as Clients;
./config.ts
1
2
3
4
5
6
import "https://deno.land/std@0.139.0/dotenv/load.ts";

export const config = {
basicSecret: Deno.env.get("BASIC_SECRET")!,
salt: Deno.env.get("SALT")!,
};
./deps.ts
1
2
3
4
5
6
7
8
9
10
export {
Application,
Request,
Router,
} from "https://deno.land/x/oak@v10.5.1/mod.ts";
export { createHash } from "https://deno.land/std@0.139.0/hash/mod.ts";
export {
SSQL,
SSQLTable,
} from "https://deno.land/x/smallorm_sqlite@0.2.1/mod.ts";
./orm.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
import { SSQL, SSQLTable } from "./deps.ts";

export class Token extends SSQLTable {
clientId = "";
token = "";
expirationAt = new Date().getTime();

expire() {
return new Date(this.expirationAt);
}
}

export const orm = new SSQL("test.db", [Token]);
./validates.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import { createHash, Request } from "./deps.ts";
import { config } from "./config.ts";
import { clients } from "./clients.ts";
import { orm, Token } from "./orm.ts";

interface AccessTokenRequestCheckResultFailure {
status: false;
}
interface AccessTokenRequestCheckResultSuccess {
status: true;
clientId: string;
}

type AccessTokenRequestCheckResult =
| AccessTokenRequestCheckResultFailure
| AccessTokenRequestCheckResultSuccess;

type AccessResourceRequestCheckResultFailure =
AccessTokenRequestCheckResultFailure;
type AccessResourceRequestCheckResultSuccess =
AccessTokenRequestCheckResultSuccess;
type AccessResourceRequestCheckResult =
| AccessResourceRequestCheckResultFailure
| AccessResourceRequestCheckResultSuccess;

export function accessResourceRequestCheck(
req: Request
): AccessResourceRequestCheckResult {
const auth = req.headers.get("authorization");
if (!auth) return { status: false };
const token = auth.split("Bearer ")[1];
if (!token) return { status: false };

const d = new Date();
d.setMinutes(d.getMinutes() - 10);

const t = orm.findMany(Token, {
where: {
clause: "token = ? AND expirationAt > ?",
values: [token, d.getTime()],
},
});

if (t.length !== 1) return { status: false };

return { status: true, clientId: t[0].clientId };
}

export async function accessTokenRequestCheck(
req: Request
): Promise<AccessTokenRequestCheckResult> {
const body = await req.body({ type: "form" });
const clientId = (await body.value).get("client_id");
const clientSecret = (await body.value).get("client_secret");
const grantType = (await body.value).get("grant_type");

if (grantType !== "client_credentials") return { status: false };
if (!clientId) return { status: false };
if (typeof clientId !== "string") return { status: false };
if (!clientSecret) return { status: false };

const client = clients[clientId];

const h = createHash("sha256");
h.update(`${config.salt}-${clientSecret}-${config.salt}`);
const clientSecretHash = h.toString();

if (clientSecretHash !== client.secret) return { status: false };

return { status: true, clientId };
}

export function checkBasicAuth(req: Request): boolean {
const auth = req.headers.get("authorization");
if (!auth) return false;
const s = auth.split("Basic ")[1];
if (!s) return false;
if (s !== config.basicSecret) return false;
return true;
}

SQLite で、時刻で where をするのが少し難しく Integer で処理するようにしてある。
たまには RFC 見ながら実装するのもいいなと思うなど。
ちゃんと実装できてるのか確認したい。

追々アプリを作っていきます。
ではでは。